Skip to content

Commit 5a8df6c

Browse files
authored
Merge pull request #46 from interruping/feat/min-total-cache
feat: get_chance() 결과 per-market TTL 캐시 추가
2 parents ec04e5b + 51d49b3 commit 5a8df6c

2 files changed

Lines changed: 157 additions & 0 deletions

File tree

src/upbeat/api/orders.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import time
34
from decimal import Decimal
45
from typing import Any
56

@@ -35,6 +36,9 @@ def _compute_bid_total(
3536
return Decimal(price)
3637

3738

39+
_DEFAULT_MIN_TOTAL_TTL: float = 60.0
40+
41+
3842
class OrdersAPI(_SyncAPIResource):
3943
_validate_min_order: bool
4044

@@ -44,9 +48,21 @@ def __init__(
4448
credentials: Credentials | None,
4549
*,
4650
validate_min_order: bool = False,
51+
min_total_ttl: float = _DEFAULT_MIN_TOTAL_TTL,
4752
) -> None:
4853
super().__init__(transport, credentials)
4954
self._validate_min_order = validate_min_order
55+
self._min_total_ttl = min_total_ttl
56+
self._min_total_cache: dict[str, tuple[str, float]] = {}
57+
58+
def _get_cached_min_total(self, market: str) -> str | None:
59+
entry = self._min_total_cache.get(market)
60+
if entry is not None:
61+
value, expiry = entry
62+
if time.monotonic() < expiry:
63+
return value
64+
del self._min_total_cache[market]
65+
return None
5066

5167
def _check_min_order(
5268
self,
@@ -61,8 +77,25 @@ def _check_min_order(
6177
total = _compute_bid_total(price, volume, ord_type)
6278
if total is None:
6379
return
80+
81+
cached = self._get_cached_min_total(market)
82+
if cached is not None:
83+
min_total = Decimal(cached)
84+
if total < min_total:
85+
raise ValidationError(
86+
f"Order total {total} is below minimum {min_total} for {market}",
87+
market=market,
88+
total=str(total),
89+
min_total=cached,
90+
)
91+
return
92+
6493
chance = self.get_chance(market=market)
6594
if chance.market.bid is not None:
95+
self._min_total_cache[market] = (
96+
chance.market.bid.min_total,
97+
time.monotonic() + self._min_total_ttl,
98+
)
6699
min_total = Decimal(chance.market.bid.min_total)
67100
if total < min_total:
68101
raise ValidationError(
@@ -305,9 +338,21 @@ def __init__(
305338
credentials: Credentials | None,
306339
*,
307340
validate_min_order: bool = False,
341+
min_total_ttl: float = _DEFAULT_MIN_TOTAL_TTL,
308342
) -> None:
309343
super().__init__(transport, credentials)
310344
self._validate_min_order = validate_min_order
345+
self._min_total_ttl = min_total_ttl
346+
self._min_total_cache: dict[str, tuple[str, float]] = {}
347+
348+
def _get_cached_min_total(self, market: str) -> str | None:
349+
entry = self._min_total_cache.get(market)
350+
if entry is not None:
351+
value, expiry = entry
352+
if time.monotonic() < expiry:
353+
return value
354+
del self._min_total_cache[market]
355+
return None
311356

312357
async def _check_min_order(
313358
self,
@@ -322,8 +367,25 @@ async def _check_min_order(
322367
total = _compute_bid_total(price, volume, ord_type)
323368
if total is None:
324369
return
370+
371+
cached = self._get_cached_min_total(market)
372+
if cached is not None:
373+
min_total = Decimal(cached)
374+
if total < min_total:
375+
raise ValidationError(
376+
f"Order total {total} is below minimum {min_total} for {market}",
377+
market=market,
378+
total=str(total),
379+
min_total=cached,
380+
)
381+
return
382+
325383
chance = await self.get_chance(market=market)
326384
if chance.market.bid is not None:
385+
self._min_total_cache[market] = (
386+
chance.market.bid.min_total,
387+
time.monotonic() + self._min_total_ttl,
388+
)
327389
min_total = Decimal(chance.market.bid.min_total)
328390
if total < min_total:
329391
raise ValidationError(

tests/api/test_orders.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,3 +700,98 @@ async def test_async_validation_passes(self) -> None:
700700
market="KRW-BTC", side="bid", ord_type="price", price="6000"
701701
)
702702
assert isinstance(result, OrderCreated)
703+
704+
705+
# ── TestMinOrderCache ──────────────────────────────────────────────────
706+
707+
708+
class TestMinOrderCache:
709+
def test_cache_hit_skips_get_chance(self) -> None:
710+
chance_calls = 0
711+
712+
def handler(request: httpx.Request) -> httpx.Response:
713+
nonlocal chance_calls
714+
if request.url.path == "/v1/orders/chance":
715+
chance_calls += 1
716+
return _multi_handler(request)
717+
718+
transport = _make_transport(handler)
719+
api = OrdersAPI(transport, CREDENTIALS, validate_min_order=True)
720+
api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000")
721+
api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000")
722+
assert chance_calls == 1
723+
724+
def test_cache_expires_after_ttl(self) -> None:
725+
chance_calls = 0
726+
727+
def handler(request: httpx.Request) -> httpx.Response:
728+
nonlocal chance_calls
729+
if request.url.path == "/v1/orders/chance":
730+
chance_calls += 1
731+
return _multi_handler(request)
732+
733+
transport = _make_transport(handler)
734+
api = OrdersAPI(
735+
transport, CREDENTIALS, validate_min_order=True, min_total_ttl=-1.0
736+
)
737+
api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000")
738+
api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000")
739+
assert chance_calls == 2
740+
741+
def test_cache_is_per_market(self) -> None:
742+
chance_calls = 0
743+
744+
def handler(request: httpx.Request) -> httpx.Response:
745+
nonlocal chance_calls
746+
if request.url.path == "/v1/orders/chance":
747+
chance_calls += 1
748+
return _multi_handler(request)
749+
750+
transport = _make_transport(handler)
751+
api = OrdersAPI(transport, CREDENTIALS, validate_min_order=True)
752+
api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000")
753+
api.create(market="KRW-ETH", side="bid", ord_type="price", price="6000")
754+
assert chance_calls == 2
755+
756+
def test_cache_hit_still_validates(self) -> None:
757+
transport = _make_transport(_multi_handler)
758+
api = OrdersAPI(transport, CREDENTIALS, validate_min_order=True)
759+
# First call populates the cache
760+
api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000")
761+
# Second call should use cache but still raise for low total
762+
with pytest.raises(ValidationError) as exc_info:
763+
api.create(market="KRW-BTC", side="bid", ord_type="price", price="3000")
764+
assert exc_info.value.min_total == "5000"
765+
766+
def test_cache_populated_on_validation_failure(self) -> None:
767+
chance_calls = 0
768+
769+
def handler(request: httpx.Request) -> httpx.Response:
770+
nonlocal chance_calls
771+
if request.url.path == "/v1/orders/chance":
772+
chance_calls += 1
773+
return _multi_handler(request)
774+
775+
transport = _make_transport(handler)
776+
api = OrdersAPI(transport, CREDENTIALS, validate_min_order=True)
777+
with pytest.raises(ValidationError):
778+
api.create(market="KRW-BTC", side="bid", ord_type="price", price="3000")
779+
with pytest.raises(ValidationError):
780+
api.create(market="KRW-BTC", side="bid", ord_type="price", price="3000")
781+
assert chance_calls == 1
782+
783+
@pytest.mark.asyncio
784+
async def test_async_cache_hit_skips_get_chance(self) -> None:
785+
chance_calls = 0
786+
787+
async def handler(request: httpx.Request) -> httpx.Response:
788+
nonlocal chance_calls
789+
if request.url.path == "/v1/orders/chance":
790+
chance_calls += 1
791+
return _multi_handler(request)
792+
793+
transport = _make_async_transport(handler)
794+
api = AsyncOrdersAPI(transport, CREDENTIALS, validate_min_order=True)
795+
await api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000")
796+
await api.create(market="KRW-BTC", side="bid", ord_type="price", price="6000")
797+
assert chance_calls == 1

0 commit comments

Comments
 (0)