From 32ce27180d059a3fb50decf942074dc8c1f24ed9 Mon Sep 17 00:00:00 2001 From: counterweight Date: Fri, 26 Dec 2025 20:08:35 +0100 Subject: [PATCH] Step 1: Add PricingConfig model and PricingRepository - Create PricingConfig model with all required fields (premium settings, trade limits) - Implement PricingRepository with singleton pattern (get_current, create_or_update) - Add comprehensive tests for repository functionality - Export model and repository in __init__.py files --- backend/models/__init__.py | 2 + backend/models/pricing_config.py | 44 +++++++ backend/repositories/__init__.py | 2 + backend/repositories/pricing.py | 83 ++++++++++++ backend/tests/test_pricing_repository.py | 160 +++++++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 backend/models/pricing_config.py create mode 100644 backend/repositories/pricing.py create mode 100644 backend/tests/test_pricing_repository.py diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 049018b..d387017 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -14,6 +14,7 @@ from .enums import ( from .exchange import Exchange from .invite import Invite from .price_history import PriceHistory +from .pricing_config import PricingConfig # Export role configuration from .role_config import ROLE_ADMIN, ROLE_DEFINITIONS, ROLE_REGULAR @@ -34,6 +35,7 @@ __all__ = [ "InviteStatus", "Permission", "PriceHistory", + "PricingConfig", "Role", "RoleConfig", "TradeDirection", diff --git a/backend/models/pricing_config.py b/backend/models/pricing_config.py new file mode 100644 index 0000000..e1e2455 --- /dev/null +++ b/backend/models/pricing_config.py @@ -0,0 +1,44 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, Integer +from sqlalchemy.orm import Mapped, mapped_column + +from database import Base + + +class PricingConfig(Base): + """Pricing configuration for exchange trades. + + Singleton pattern: Only one record should exist in the database. + Updates modify the existing record rather than creating new ones. + """ + + __tablename__ = "pricing_config" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # Premium settings + premium_buy: Mapped[int] = mapped_column(Integer, nullable=False) # -100 to 100 + premium_sell: Mapped[int] = mapped_column(Integer, nullable=False) # -100 to 100 + small_trade_threshold_eur: Mapped[int] = mapped_column( + Integer, nullable=False + ) # EUR cents, e.g., 20000 = €200 + small_trade_extra_premium: Mapped[int] = mapped_column( + Integer, nullable=False + ) # -100 to 100 + + # Trade amount limits (EUR cents) + eur_min_buy: Mapped[int] = mapped_column(Integer, nullable=False) + eur_max_buy: Mapped[int] = mapped_column(Integer, nullable=False) + eur_min_sell: Mapped[int] = mapped_column(Integer, nullable=False) + eur_max_sell: Mapped[int] = mapped_column(Integer, nullable=False) + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) diff --git a/backend/repositories/__init__.py b/backend/repositories/__init__.py index 805dee7..f6950df 100644 --- a/backend/repositories/__init__.py +++ b/backend/repositories/__init__.py @@ -4,6 +4,7 @@ from repositories.availability import AvailabilityRepository from repositories.exchange import ExchangeRepository from repositories.invite import InviteRepository from repositories.price import PriceRepository +from repositories.pricing import PricingRepository from repositories.role import RoleRepository from repositories.user import UserRepository @@ -12,6 +13,7 @@ __all__ = [ "ExchangeRepository", "InviteRepository", "PriceRepository", + "PricingRepository", "RoleRepository", "UserRepository", ] diff --git a/backend/repositories/pricing.py b/backend/repositories/pricing.py new file mode 100644 index 0000000..2b84b59 --- /dev/null +++ b/backend/repositories/pricing.py @@ -0,0 +1,83 @@ +"""Pricing repository for database queries.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from models import PricingConfig + + +class PricingRepository: + """Repository for pricing configuration database queries.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_current(self) -> PricingConfig | None: + """Get the current pricing configuration (singleton). + + Returns: + PricingConfig if exists, None otherwise + """ + result = await self.db.execute(select(PricingConfig).limit(1)) + return result.scalar_one_or_none() + + async def create_or_update( + self, + premium_buy: int, + premium_sell: int, + small_trade_threshold_eur: int, + small_trade_extra_premium: int, + eur_min_buy: int, + eur_max_buy: int, + eur_min_sell: int, + eur_max_sell: int, + ) -> PricingConfig: + """Create or update pricing configuration (singleton pattern). + + If no config exists, creates a new one. + If config exists, updates the existing record. + + Args: + premium_buy: Premium percentage for BUY direction (-100 to 100) + premium_sell: Premium percentage for SELL direction (-100 to 100) + small_trade_threshold_eur: Threshold in EUR cents for small trade + extra premium + small_trade_extra_premium: Extra premium percentage for small trades + (-100 to 100) + eur_min_buy: Minimum trade amount in EUR cents for BUY + eur_max_buy: Maximum trade amount in EUR cents for BUY + eur_min_sell: Minimum trade amount in EUR cents for SELL + eur_max_sell: Maximum trade amount in EUR cents for SELL + + Returns: + Created or updated PricingConfig record + """ + config = await self.get_current() + + if config is None: + # Create new config + config = PricingConfig( + premium_buy=premium_buy, + premium_sell=premium_sell, + small_trade_threshold_eur=small_trade_threshold_eur, + small_trade_extra_premium=small_trade_extra_premium, + eur_min_buy=eur_min_buy, + eur_max_buy=eur_max_buy, + eur_min_sell=eur_min_sell, + eur_max_sell=eur_max_sell, + ) + self.db.add(config) + else: + # Update existing config + config.premium_buy = premium_buy + config.premium_sell = premium_sell + config.small_trade_threshold_eur = small_trade_threshold_eur + config.small_trade_extra_premium = small_trade_extra_premium + config.eur_min_buy = eur_min_buy + config.eur_max_buy = eur_max_buy + config.eur_min_sell = eur_min_sell + config.eur_max_sell = eur_max_sell + + await self.db.commit() + await self.db.refresh(config) + return config diff --git a/backend/tests/test_pricing_repository.py b/backend/tests/test_pricing_repository.py new file mode 100644 index 0000000..3cfa328 --- /dev/null +++ b/backend/tests/test_pricing_repository.py @@ -0,0 +1,160 @@ +""" +Pricing Repository Tests + +Tests for the pricing configuration repository. +""" + +import pytest + +from models import PricingConfig +from repositories.pricing import PricingRepository + + +class TestPricingRepository: + """Test the PricingRepository class.""" + + @pytest.mark.asyncio + async def test_get_current_returns_none_when_no_config(self, client_factory): + """get_current() returns None when no config exists.""" + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + config = await repo.get_current() + assert config is None + + @pytest.mark.asyncio + async def test_create_or_update_creates_first_config(self, client_factory): + """create_or_update() creates a new config when none exists.""" + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + config = await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, # €200 + small_trade_extra_premium=2, + eur_min_buy=10000, # €100 + eur_max_buy=300000, # €3000 + eur_min_sell=10000, + eur_max_sell=300000, + ) + + assert config is not None + assert config.id is not None + assert config.premium_buy == 5 + assert config.premium_sell == 5 + assert config.small_trade_threshold_eur == 20000 + assert config.small_trade_extra_premium == 2 + assert config.eur_min_buy == 10000 + assert config.eur_max_buy == 300000 + assert config.eur_min_sell == 10000 + assert config.eur_max_sell == 300000 + assert config.created_at is not None + assert config.updated_at is not None + + @pytest.mark.asyncio + async def test_create_or_update_updates_existing_config(self, client_factory): + """create_or_update() updates existing config (singleton behavior).""" + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + + # Create initial config + config1 = 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, + ) + original_id = config1.id + original_created_at = config1.created_at + + # Update config + config2 = await repo.create_or_update( + premium_buy=7, + premium_sell=6, + small_trade_threshold_eur=25000, + small_trade_extra_premium=3, + eur_min_buy=15000, + eur_max_buy=350000, + eur_min_sell=12000, + eur_max_sell=320000, + ) + + # Should be the same record (singleton) + assert config2.id == original_id + assert config2.created_at == original_created_at + assert config2.premium_buy == 7 + assert config2.premium_sell == 6 + assert config2.small_trade_threshold_eur == 25000 + assert config2.small_trade_extra_premium == 3 + assert config2.eur_min_buy == 15000 + assert config2.eur_max_buy == 350000 + assert config2.eur_min_sell == 12000 + assert config2.eur_max_sell == 320000 + + @pytest.mark.asyncio + async def test_get_current_returns_existing_config(self, client_factory): + """get_current() returns existing config.""" + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + + # Create config + created_config = 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, + ) + + # Retrieve config + retrieved_config = await repo.get_current() + + assert retrieved_config is not None + assert retrieved_config.id == created_config.id + assert retrieved_config.premium_buy == 5 + assert retrieved_config.premium_sell == 5 + + @pytest.mark.asyncio + async def test_singleton_pattern_only_one_config_exists(self, client_factory): + """Only one config record can exist (singleton pattern).""" + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + + # Create config multiple times + config1 = 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, + ) + + config2 = await repo.create_or_update( + premium_buy=10, + premium_sell=10, + small_trade_threshold_eur=30000, + small_trade_extra_premium=5, + eur_min_buy=20000, + eur_max_buy=400000, + eur_min_sell=20000, + eur_max_sell=400000, + ) + + # Should be same record + assert config1.id == config2.id + + # Verify only one record exists + from sqlalchemy import select + + result = await db.execute(select(PricingConfig)) + all_configs = list(result.scalars().all()) + assert len(all_configs) == 1