Skip to content

Commit ec04e5b

Browse files
authored
Merge pull request #44 from interruping/feat/validate-min-order
feat: 매수 주문 최소금액 클라이언트 검증 옵션 추가
2 parents c2b7666 + e07bb53 commit ec04e5b

5 files changed

Lines changed: 260 additions & 2 deletions

File tree

src/upbeat/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
RemainingRequest,
4343
UnprocessableEntityError,
4444
UpbeatError,
45+
ValidationError,
4546
WebSocketClosedError,
4647
WebSocketConnectionError,
4748
WebSocketError,
@@ -137,6 +138,7 @@
137138
"RemainingRequest",
138139
"UnprocessableEntityError",
139140
"UpbeatError",
141+
"ValidationError",
140142
"WebSocketClosedError",
141143
"WebSocketConnectionError",
142144
"WebSocketError",

src/upbeat/_client.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def __init__(
4242
logger: Logger | None = None,
4343
http_client: httpx.Client | None = None,
4444
event_hooks: dict[str, list[Any]] | None = None,
45+
validate_min_order: bool = False,
4546
) -> None:
4647
if (access_key is None) != (secret_key is None):
4748
raise ValueError(
@@ -59,6 +60,7 @@ def __init__(
5960
self._max_retries = max_retries
6061
self._auto_throttle = auto_throttle
6162
self._logger = logger
63+
self._validate_min_order = validate_min_order
6264
self._owns_http_client = http_client is None
6365
self._closed = False
6466

@@ -84,7 +86,11 @@ def accounts(self) -> AccountsAPI:
8486

8587
@cached_property
8688
def orders(self) -> OrdersAPI:
87-
return OrdersAPI(self._transport, self._credentials)
89+
return OrdersAPI(
90+
self._transport,
91+
self._credentials,
92+
validate_min_order=self._validate_min_order,
93+
)
8894

8995
@cached_property
9096
def deposits(self) -> DepositsAPI:
@@ -131,6 +137,7 @@ def with_options(
131137
max_retries: int | None = None,
132138
auto_throttle: bool | None = None,
133139
logger: Logger | None = None,
140+
validate_min_order: bool | None = None,
134141
) -> Upbeat:
135142
new = Upbeat.__new__(Upbeat)
136143
new._credentials = self._credentials
@@ -141,6 +148,11 @@ def with_options(
141148
auto_throttle if auto_throttle is not None else self._auto_throttle
142149
)
143150
new._logger = logger if logger is not None else self._logger
151+
new._validate_min_order = (
152+
validate_min_order
153+
if validate_min_order is not None
154+
else self._validate_min_order
155+
)
144156
new._owns_http_client = False
145157
new._closed = False
146158

@@ -176,6 +188,7 @@ def __init__(
176188
logger: Logger | None = None,
177189
http_client: httpx.AsyncClient | None = None,
178190
event_hooks: dict[str, list[Any]] | None = None,
191+
validate_min_order: bool = False,
179192
) -> None:
180193
if (access_key is None) != (secret_key is None):
181194
raise ValueError(
@@ -193,6 +206,7 @@ def __init__(
193206
self._max_retries = max_retries
194207
self._auto_throttle = auto_throttle
195208
self._logger = logger
209+
self._validate_min_order = validate_min_order
196210
self._owns_http_client = http_client is None
197211
self._closed = False
198212

@@ -218,7 +232,11 @@ def accounts(self) -> AsyncAccountsAPI:
218232

219233
@cached_property
220234
def orders(self) -> AsyncOrdersAPI:
221-
return AsyncOrdersAPI(self._transport, self._credentials)
235+
return AsyncOrdersAPI(
236+
self._transport,
237+
self._credentials,
238+
validate_min_order=self._validate_min_order,
239+
)
222240

223241
@cached_property
224242
def deposits(self) -> AsyncDepositsAPI:
@@ -269,6 +287,7 @@ def with_options(
269287
max_retries: int | None = None,
270288
auto_throttle: bool | None = None,
271289
logger: Logger | None = None,
290+
validate_min_order: bool | None = None,
272291
) -> AsyncUpbeat:
273292
new = AsyncUpbeat.__new__(AsyncUpbeat)
274293
new._credentials = self._credentials
@@ -279,6 +298,11 @@ def with_options(
279298
auto_throttle if auto_throttle is not None else self._auto_throttle
280299
)
281300
new._logger = logger if logger is not None else self._logger
301+
new._validate_min_order = (
302+
validate_min_order
303+
if validate_min_order is not None
304+
else self._validate_min_order
305+
)
282306
new._owns_http_client = False
283307
new._closed = False
284308

src/upbeat/_errors.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,30 @@ def __init__(self, message: str) -> None:
3939
self.message = message
4040

4141

42+
# ── Validation errors ─────────────────────────────────────────────────
43+
44+
45+
class ValidationError(UpbeatError):
46+
"""Raised when client-side validation catches an invalid order before sending."""
47+
48+
market: str
49+
total: str
50+
min_total: str
51+
52+
def __init__(
53+
self,
54+
message: str,
55+
*,
56+
market: str,
57+
total: str,
58+
min_total: str,
59+
) -> None:
60+
super().__init__(message)
61+
self.market = market
62+
self.total = total
63+
self.min_total = min_total
64+
65+
4266
# ── API errors ───────────────────────────────────────────────────────────
4367

4468

src/upbeat/api/orders.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
from __future__ import annotations
22

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

6+
from upbeat._auth import Credentials
57
from upbeat._base import _AsyncAPIResource, _SyncAPIResource
8+
from upbeat._errors import ValidationError
9+
from upbeat._http import AsyncTransport, SyncTransport
610
from upbeat.types.order import (
711
CancelAndNewOrderResponse,
812
CancelResult,
@@ -20,7 +24,54 @@ def _filter_params(**kwargs: Any) -> dict[str, Any]:
2024
return {k: v for k, v in kwargs.items() if v is not None}
2125

2226

27+
def _compute_bid_total(
28+
price: str | None, volume: str | None, ord_type: str
29+
) -> Decimal | None:
30+
"""Return the total KRW value of a bid order, or None if indeterminate."""
31+
if price is None:
32+
return None
33+
if ord_type == "limit":
34+
return Decimal(price) * Decimal(volume) if volume is not None else None
35+
return Decimal(price)
36+
37+
2338
class OrdersAPI(_SyncAPIResource):
39+
_validate_min_order: bool
40+
41+
def __init__(
42+
self,
43+
transport: SyncTransport,
44+
credentials: Credentials | None,
45+
*,
46+
validate_min_order: bool = False,
47+
) -> None:
48+
super().__init__(transport, credentials)
49+
self._validate_min_order = validate_min_order
50+
51+
def _check_min_order(
52+
self,
53+
market: str,
54+
side: str,
55+
price: str | None,
56+
volume: str | None,
57+
ord_type: str,
58+
) -> None:
59+
if not self._validate_min_order or side != "bid":
60+
return
61+
total = _compute_bid_total(price, volume, ord_type)
62+
if total is None:
63+
return
64+
chance = self.get_chance(market=market)
65+
if chance.market.bid is not None:
66+
min_total = Decimal(chance.market.bid.min_total)
67+
if total < min_total:
68+
raise ValidationError(
69+
f"Order total {total} is below minimum {min_total} for {market}",
70+
market=market,
71+
total=str(total),
72+
min_total=chance.market.bid.min_total,
73+
)
74+
2475
def create(
2576
self,
2677
*,
@@ -33,6 +84,7 @@ def create(
3384
time_in_force: str | None = None,
3485
smp_type: str | None = None,
3586
) -> OrderCreated:
87+
self._check_min_order(market, side, price, volume, ord_type)
3688
json_body = _filter_params(
3789
market=market,
3890
side=side,
@@ -60,6 +112,7 @@ def create_test(
60112
time_in_force: str | None = None,
61113
smp_type: str | None = None,
62114
) -> OrderCreated:
115+
self._check_min_order(market, side, price, volume, ord_type)
63116
json_body = _filter_params(
64117
market=market,
65118
side=side,
@@ -244,6 +297,42 @@ def get_chance(self, *, market: str) -> OrderChance:
244297

245298

246299
class AsyncOrdersAPI(_AsyncAPIResource):
300+
_validate_min_order: bool
301+
302+
def __init__(
303+
self,
304+
transport: AsyncTransport,
305+
credentials: Credentials | None,
306+
*,
307+
validate_min_order: bool = False,
308+
) -> None:
309+
super().__init__(transport, credentials)
310+
self._validate_min_order = validate_min_order
311+
312+
async def _check_min_order(
313+
self,
314+
market: str,
315+
side: str,
316+
price: str | None,
317+
volume: str | None,
318+
ord_type: str,
319+
) -> None:
320+
if not self._validate_min_order or side != "bid":
321+
return
322+
total = _compute_bid_total(price, volume, ord_type)
323+
if total is None:
324+
return
325+
chance = await self.get_chance(market=market)
326+
if chance.market.bid is not None:
327+
min_total = Decimal(chance.market.bid.min_total)
328+
if total < min_total:
329+
raise ValidationError(
330+
f"Order total {total} is below minimum {min_total} for {market}",
331+
market=market,
332+
total=str(total),
333+
min_total=chance.market.bid.min_total,
334+
)
335+
247336
async def create(
248337
self,
249338
*,
@@ -256,6 +345,7 @@ async def create(
256345
time_in_force: str | None = None,
257346
smp_type: str | None = None,
258347
) -> OrderCreated:
348+
await self._check_min_order(market, side, price, volume, ord_type)
259349
json_body = _filter_params(
260350
market=market,
261351
side=side,
@@ -283,6 +373,7 @@ async def create_test(
283373
time_in_force: str | None = None,
284374
smp_type: str | None = None,
285375
) -> OrderCreated:
376+
await self._check_min_order(market, side, price, volume, ord_type)
286377
json_body = _filter_params(
287378
market=market,
288379
side=side,

0 commit comments

Comments
 (0)