From 747e337a5094ba2c819387910da2971a3c55535b Mon Sep 17 00:00:00 2001 From: gylim Date: Sun, 22 Mar 2026 22:44:21 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20get=5Fchance()=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20per-market=20TTL=20=EC=BA=90=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DCA 등 동일 마켓 반복 주문 시 매번 get_chance() API를 호출하여 레이트 리밋이 불필요하게 소진되는 문제를 해결한다. min_total 값을 마켓별로 캐시하고 TTL(기본 60초) 경과 시 만료한다. Closes #45 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/upbeat/api/orders.py | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/upbeat/api/orders.py b/src/upbeat/api/orders.py index 77ddd4c..cfbdde8 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,20 @@ 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 + return None def _check_min_order( self, @@ -61,8 +76,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 +337,20 @@ 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 + return None async def _check_min_order( self, @@ -322,8 +365,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( From 783388f13f23c0c7d528c2cfc55ceb41cbcaa3ac Mon Sep 17 00:00:00 2001 From: gylim Date: Sun, 22 Mar 2026 22:44:25 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20min=5Ftotal=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캐시 히트, TTL 만료, per-market 격리, 캐시 히트 시 검증, 실패 시에도 캐시 저장, async 캐시 히트 테스트를 추가한다. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/api/test_orders.py | 95 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/api/test_orders.py b/tests/api/test_orders.py index 4961f2a..42ad32f 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=0.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 From 51d49b392e39af903d087cb88132cbf6d5650378 Mon Sep 17 00:00:00 2001 From: gylim Date: Sun, 22 Mar 2026 22:48:28 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=EB=A7=8C=EB=A3=8C=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EC=97=94=ED=8A=B8=EB=A6=AC=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20TTL=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _get_cached_min_total에서 만료 감지 시 del로 엔트리 정리 - TTL 만료 테스트에서 min_total_ttl=-1.0 사용하여 의도 명확화 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/upbeat/api/orders.py | 2 ++ tests/api/test_orders.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/upbeat/api/orders.py b/src/upbeat/api/orders.py index cfbdde8..d3f4859 100644 --- a/src/upbeat/api/orders.py +++ b/src/upbeat/api/orders.py @@ -61,6 +61,7 @@ def _get_cached_min_total(self, market: str) -> str | None: value, expiry = entry if time.monotonic() < expiry: return value + del self._min_total_cache[market] return None def _check_min_order( @@ -350,6 +351,7 @@ def _get_cached_min_total(self, market: str) -> str | None: value, expiry = entry if time.monotonic() < expiry: return value + del self._min_total_cache[market] return None async def _check_min_order( diff --git a/tests/api/test_orders.py b/tests/api/test_orders.py index 42ad32f..8fc788e 100644 --- a/tests/api/test_orders.py +++ b/tests/api/test_orders.py @@ -732,7 +732,7 @@ def handler(request: httpx.Request) -> httpx.Response: transport = _make_transport(handler) api = OrdersAPI( - transport, CREDENTIALS, validate_min_order=True, min_total_ttl=0.0 + 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")