diff --git a/src/upbeat/api/orders.py b/src/upbeat/api/orders.py index 77ddd4c..d3f4859 100644 --- a/src/upbeat/api/orders.py +++ b/src/upbeat/api/orders.py @@ -1,5 +1,6 @@ from __future__ import annotations +import time from decimal import Decimal from typing import Any @@ -35,6 +36,9 @@ def _compute_bid_total( return Decimal(price) +_DEFAULT_MIN_TOTAL_TTL: float = 60.0 + + class OrdersAPI(_SyncAPIResource): _validate_min_order: bool @@ -44,9 +48,21 @@ def __init__( credentials: Credentials | None, *, validate_min_order: bool = False, + min_total_ttl: float = _DEFAULT_MIN_TOTAL_TTL, ) -> None: super().__init__(transport, credentials) self._validate_min_order = validate_min_order + self._min_total_ttl = min_total_ttl + self._min_total_cache: dict[str, tuple[str, float]] = {} + + def _get_cached_min_total(self, market: str) -> str | None: + entry = self._min_total_cache.get(market) + if entry is not None: + value, expiry = entry + if time.monotonic() < expiry: + return value + del self._min_total_cache[market] + return None def _check_min_order( self, @@ -61,8 +77,25 @@ def _check_min_order( total = _compute_bid_total(price, volume, ord_type) if total is None: return + + cached = self._get_cached_min_total(market) + if cached is not None: + min_total = Decimal(cached) + if total < min_total: + raise ValidationError( + f"Order total {total} is below minimum {min_total} for {market}", + market=market, + total=str(total), + min_total=cached, + ) + return + chance = self.get_chance(market=market) if chance.market.bid is not None: + self._min_total_cache[market] = ( + chance.market.bid.min_total, + time.monotonic() + self._min_total_ttl, + ) min_total = Decimal(chance.market.bid.min_total) if total < min_total: raise ValidationError( @@ -305,9 +338,21 @@ def __init__( credentials: Credentials | None, *, validate_min_order: bool = False, + min_total_ttl: float = _DEFAULT_MIN_TOTAL_TTL, ) -> None: super().__init__(transport, credentials) self._validate_min_order = validate_min_order + self._min_total_ttl = min_total_ttl + self._min_total_cache: dict[str, tuple[str, float]] = {} + + def _get_cached_min_total(self, market: str) -> str | None: + entry = self._min_total_cache.get(market) + if entry is not None: + value, expiry = entry + if time.monotonic() < expiry: + return value + del self._min_total_cache[market] + return None async def _check_min_order( self, @@ -322,8 +367,25 @@ async def _check_min_order( total = _compute_bid_total(price, volume, ord_type) if total is None: return + + cached = self._get_cached_min_total(market) + if cached is not None: + min_total = Decimal(cached) + if total < min_total: + raise ValidationError( + f"Order total {total} is below minimum {min_total} for {market}", + market=market, + total=str(total), + min_total=cached, + ) + return + chance = await self.get_chance(market=market) if chance.market.bid is not None: + self._min_total_cache[market] = ( + chance.market.bid.min_total, + time.monotonic() + self._min_total_ttl, + ) min_total = Decimal(chance.market.bid.min_total) if total < min_total: raise ValidationError( diff --git a/tests/api/test_orders.py b/tests/api/test_orders.py index 4961f2a..8fc788e 100644 --- a/tests/api/test_orders.py +++ b/tests/api/test_orders.py @@ -700,3 +700,98 @@ async def test_async_validation_passes(self) -> None: market="KRW-BTC", side="bid", ord_type="price", price="6000" ) assert isinstance(result, OrderCreated) + + +# ── TestMinOrderCache ────────────────────────────────────────────────── + + +class TestMinOrderCache: + def test_cache_hit_skips_get_chance(self) -> None: + chance_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal chance_calls + if request.url.path == "/v1/orders/chance": + chance_calls += 1 + return _multi_handler(request) + + transport = _make_transport(handler) + api = OrdersAPI(transport, CREDENTIALS, validate_min_order=True) + api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000") + api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000") + assert chance_calls == 1 + + def test_cache_expires_after_ttl(self) -> None: + chance_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal chance_calls + if request.url.path == "/v1/orders/chance": + chance_calls += 1 + return _multi_handler(request) + + transport = _make_transport(handler) + api = OrdersAPI( + transport, CREDENTIALS, validate_min_order=True, min_total_ttl=-1.0 + ) + api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000") + api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000") + assert chance_calls == 2 + + def test_cache_is_per_market(self) -> None: + chance_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal chance_calls + if request.url.path == "/v1/orders/chance": + chance_calls += 1 + return _multi_handler(request) + + transport = _make_transport(handler) + api = OrdersAPI(transport, CREDENTIALS, validate_min_order=True) + api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000") + api.create(market="KRW-ETH", side="bid", ord_type="price", price="6000") + assert chance_calls == 2 + + def test_cache_hit_still_validates(self) -> None: + transport = _make_transport(_multi_handler) + api = OrdersAPI(transport, CREDENTIALS, validate_min_order=True) + # First call populates the cache + api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000") + # Second call should use cache but still raise for low total + with pytest.raises(ValidationError) as exc_info: + api.create(market="KRW-BTC", side="bid", ord_type="price", price="3000") + assert exc_info.value.min_total == "5000" + + def test_cache_populated_on_validation_failure(self) -> None: + chance_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal chance_calls + if request.url.path == "/v1/orders/chance": + chance_calls += 1 + return _multi_handler(request) + + transport = _make_transport(handler) + api = OrdersAPI(transport, CREDENTIALS, validate_min_order=True) + with pytest.raises(ValidationError): + api.create(market="KRW-BTC", side="bid", ord_type="price", price="3000") + with pytest.raises(ValidationError): + api.create(market="KRW-BTC", side="bid", ord_type="price", price="3000") + assert chance_calls == 1 + + @pytest.mark.asyncio + async def test_async_cache_hit_skips_get_chance(self) -> None: + chance_calls = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal chance_calls + if request.url.path == "/v1/orders/chance": + chance_calls += 1 + return _multi_handler(request) + + transport = _make_async_transport(handler) + api = AsyncOrdersAPI(transport, CREDENTIALS, validate_min_order=True) + await api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000") + await api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000") + assert chance_calls == 1