buda-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
buda/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ from importlib.metadata import version
2
+
3
+ from buda.core.providers import DotEnvCredentials, EnvCredentials, StaticCredentials
4
+ from buda.core.settings import BudaSettings
5
+ from buda.rest.client.async_ import AsyncBudaClient
6
+ from buda.rest.client.sync_ import BudaClient
7
+ from buda.rest.models.orders import OrderCreate
8
+ from buda.socket import BudaWebSocketClient, Channel
9
+
10
+ __version__ = version("buda-client")
11
+
12
+ __all__ = (
13
+ "AsyncBudaClient",
14
+ "BudaClient",
15
+ "BudaSettings",
16
+ "BudaWebSocketClient",
17
+ "Channel",
18
+ "DotEnvCredentials",
19
+ "EnvCredentials",
20
+ "OrderCreate",
21
+ "StaticCredentials",
22
+ )
buda/core/__init__.py ADDED
File without changes
buda/core/auth.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hmac
5
+ import time
6
+ from typing import TYPE_CHECKING
7
+
8
+ import httpx
9
+ from httpx import Request
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Generator
13
+
14
+ from httpx import Response
15
+
16
+
17
+ class BudaAuth(httpx.Auth):
18
+ """Attach Buda HMAC authentication to the Request object."""
19
+
20
+ __slots__ = (
21
+ "api_key",
22
+ "api_secret",
23
+ )
24
+
25
+ def __init__(self, api_key: str, api_secret: str):
26
+ self.api_key = api_key
27
+ self.api_secret = api_secret
28
+
29
+ def get_nonce(self) -> str:
30
+ """Generate a nonce (timestamp in microseconds)"""
31
+ return str(int(time.time() * 1e6))
32
+
33
+ def sign(self, request: httpx.Request, nonce: str) -> str:
34
+ """Create the HMAC signature for the request."""
35
+ components = [request.method, request.url.path]
36
+
37
+ if request.content:
38
+ body = base64.b64encode(request.content).decode()
39
+ components.append(body)
40
+ components.append(nonce)
41
+ message = " ".join(components)
42
+
43
+ hash = hmac.new(key=self.api_secret.encode(), msg=message.encode(), digestmod="sha384")
44
+
45
+ return hash.hexdigest()
46
+
47
+ def auth_flow(self, request: Request) -> Generator[Request, Response]:
48
+ nonce = self.get_nonce()
49
+ signature = self.sign(request, nonce)
50
+
51
+ request.headers["X-SBTC-APIKEY"] = self.api_key
52
+ request.headers["X-SBTC-SIGNATURE"] = signature
53
+ request.headers["X-SBTC-NONCE"] = nonce
54
+
55
+ yield request
buda/core/limiter.py ADDED
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import threading
6
+ import time
7
+ from collections import deque
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from buda.core.settings import BudaSettings
12
+
13
+ logger = logging.getLogger("buda.limiter")
14
+
15
+
16
+ class SyncRateLimiter:
17
+ """Proactive sliding-window rate limiter for synchronous requests.
18
+
19
+ Tracks per-second (shared) and per-minute (auth vs unauth) request
20
+ timestamps and sleeps when the next request would exceed limits.
21
+
22
+ Each ``BudaClient`` instance owns its own limiter, since Buda rate
23
+ limits are scoped per IP (unauthenticated) and per API key (authenticated).
24
+ """
25
+
26
+ __slots__ = (
27
+ "_enabled",
28
+ "_lock",
29
+ "_per_minute_auth",
30
+ "_per_minute_auth_limit",
31
+ "_per_minute_unauth",
32
+ "_per_minute_unauth_limit",
33
+ "_per_second",
34
+ "_per_second_limit",
35
+ )
36
+
37
+ def __init__(self, settings: BudaSettings) -> None:
38
+ self._enabled = settings.rate_limit_enabled
39
+ self._per_second_limit = settings.rate_limit_per_second
40
+ self._per_minute_auth_limit = settings.rate_limit_auth_per_minute
41
+ self._per_minute_unauth_limit = settings.rate_limit_unauth_per_minute
42
+
43
+ self._per_second: deque[float] = deque()
44
+ self._per_minute_auth: deque[float] = deque()
45
+ self._per_minute_unauth: deque[float] = deque()
46
+ self._lock = threading.Lock()
47
+
48
+ def _prune(self, window: deque[float], cutoff: float) -> None:
49
+ while window and window[0] <= cutoff:
50
+ window.popleft()
51
+
52
+ def acquire(self, *, authenticated: bool) -> None:
53
+ if not self._enabled:
54
+ return
55
+
56
+ with self._lock:
57
+ while True:
58
+ now = time.monotonic()
59
+
60
+ # Prune expired timestamps
61
+ self._prune(self._per_second, now - 1.0)
62
+ if authenticated:
63
+ self._prune(self._per_minute_auth, now - 60.0)
64
+ else:
65
+ self._prune(self._per_minute_unauth, now - 60.0)
66
+
67
+ # Calculate how long we need to wait (if at all)
68
+ wait = 0.0
69
+
70
+ if len(self._per_second) >= self._per_second_limit:
71
+ wait = max(wait, self._per_second[0] + 1.0 - now)
72
+
73
+ if authenticated:
74
+ if len(self._per_minute_auth) >= self._per_minute_auth_limit:
75
+ wait = max(wait, self._per_minute_auth[0] + 60.0 - now)
76
+ else:
77
+ if len(self._per_minute_unauth) >= self._per_minute_unauth_limit:
78
+ wait = max(wait, self._per_minute_unauth[0] + 60.0 - now)
79
+
80
+ if wait > 0:
81
+ logger.warning(
82
+ "Rate limit approaching — sleeping %.2fs before request (authenticated=%s)",
83
+ wait,
84
+ authenticated,
85
+ )
86
+ # Release lock while sleeping so other threads aren't blocked
87
+ self._lock.release()
88
+ try:
89
+ time.sleep(wait)
90
+ finally:
91
+ self._lock.acquire()
92
+ continue # Re-check after sleeping
93
+
94
+ # Record the request timestamp
95
+ self._per_second.append(now)
96
+ if authenticated:
97
+ self._per_minute_auth.append(now)
98
+ else:
99
+ self._per_minute_unauth.append(now)
100
+ return
101
+
102
+
103
+ class AsyncRateLimiter:
104
+ """Proactive sliding-window rate limiter for asynchronous requests.
105
+
106
+ Same logic as :class:`SyncRateLimiter` but uses ``asyncio.Lock`` and
107
+ ``asyncio.sleep``.
108
+ """
109
+
110
+ __slots__ = (
111
+ "_enabled",
112
+ "_lock",
113
+ "_per_minute_auth",
114
+ "_per_minute_auth_limit",
115
+ "_per_minute_unauth",
116
+ "_per_minute_unauth_limit",
117
+ "_per_second",
118
+ "_per_second_limit",
119
+ )
120
+
121
+ def __init__(self, settings: BudaSettings) -> None:
122
+ self._enabled = settings.rate_limit_enabled
123
+ self._per_second_limit = settings.rate_limit_per_second
124
+ self._per_minute_auth_limit = settings.rate_limit_auth_per_minute
125
+ self._per_minute_unauth_limit = settings.rate_limit_unauth_per_minute
126
+
127
+ self._per_second: deque[float] = deque()
128
+ self._per_minute_auth: deque[float] = deque()
129
+ self._per_minute_unauth: deque[float] = deque()
130
+ self._lock = asyncio.Lock()
131
+
132
+ def _prune(self, window: deque[float], cutoff: float) -> None:
133
+ while window and window[0] <= cutoff:
134
+ window.popleft()
135
+
136
+ async def acquire(self, *, authenticated: bool) -> None:
137
+ if not self._enabled:
138
+ return
139
+
140
+ async with self._lock:
141
+ while True:
142
+ now = time.monotonic()
143
+
144
+ self._prune(self._per_second, now - 1.0)
145
+ if authenticated:
146
+ self._prune(self._per_minute_auth, now - 60.0)
147
+ else:
148
+ self._prune(self._per_minute_unauth, now - 60.0)
149
+
150
+ wait = 0.0
151
+
152
+ if len(self._per_second) >= self._per_second_limit:
153
+ wait = max(wait, self._per_second[0] + 1.0 - now)
154
+
155
+ if authenticated:
156
+ if len(self._per_minute_auth) >= self._per_minute_auth_limit:
157
+ wait = max(wait, self._per_minute_auth[0] + 60.0 - now)
158
+ else:
159
+ if len(self._per_minute_unauth) >= self._per_minute_unauth_limit:
160
+ wait = max(wait, self._per_minute_unauth[0] + 60.0 - now)
161
+
162
+ if wait > 0:
163
+ logger.warning(
164
+ "Rate limit approaching — sleeping %.2fs before request (authenticated=%s)",
165
+ wait,
166
+ authenticated,
167
+ )
168
+ await asyncio.sleep(wait)
169
+ continue
170
+
171
+ self._per_second.append(now)
172
+ if authenticated:
173
+ self._per_minute_auth.append(now)
174
+ else:
175
+ self._per_minute_unauth.append(now)
176
+ return
buda/core/providers.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import Field, SecretStr, model_serializer
6
+ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
7
+
8
+
9
+ class BudaCredentials(BaseSettings):
10
+ """Buda API credentials."""
11
+
12
+ api_key: SecretStr = Field(..., validation_alias="buda_api_key")
13
+ api_secret: SecretStr = Field(..., validation_alias="buda_api_secret")
14
+
15
+ @model_serializer(mode="plain", return_type=dict[str, str])
16
+ def serialize(self) -> dict[str, str]:
17
+ return {
18
+ "api_key": self.api_key.get_secret_value(),
19
+ "api_secret": self.api_secret.get_secret_value(),
20
+ }
21
+
22
+
23
+ class StaticCredentials(BudaCredentials):
24
+ """Provider that uses static credentials."""
25
+
26
+ def __init__(self, *, api_key: str, api_secret: str) -> None:
27
+ super().__init__(buda_api_key=api_key, buda_api_secret=api_secret) # type: ignore
28
+
29
+
30
+ class EnvCredentials(BudaCredentials):
31
+ """Provider that loads credentials from environment variables."""
32
+
33
+ model_config = SettingsConfigDict(
34
+ env_prefix="BUDA_",
35
+ )
36
+
37
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
38
+ super().__init__(*args, **kwargs)
39
+
40
+
41
+ class DotEnvCredentials(BudaCredentials):
42
+ """Provider that loads credentials from a .env file."""
43
+
44
+ model_config = SettingsConfigDict(env_file=".env")
45
+
46
+ def __init__(self, env_file: str = ".env", **kwargs: Any) -> None:
47
+ super().__init__(_env_file=env_file, **kwargs) # type: ignore
48
+
49
+ @classmethod
50
+ def settings_customise_sources(
51
+ cls,
52
+ settings_cls: type[BaseSettings],
53
+ init_settings: PydanticBaseSettingsSource,
54
+ env_settings: PydanticBaseSettingsSource,
55
+ dotenv_settings: PydanticBaseSettingsSource,
56
+ file_secret_settings: PydanticBaseSettingsSource,
57
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
58
+ return (dotenv_settings,)
buda/core/retry.py ADDED
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import logging
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from httpx import HTTPStatusError
8
+ from tenacity import (
9
+ AsyncRetrying,
10
+ RetryCallState,
11
+ Retrying,
12
+ before_sleep_log,
13
+ retry_if_exception,
14
+ stop_after_attempt,
15
+ wait_exponential,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable
20
+
21
+ from tenacity.wait import WaitBaseT
22
+
23
+ from buda.core.settings import BudaSettings
24
+
25
+ logger = logging.getLogger("buda.retry")
26
+
27
+ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 500, 503})
28
+
29
+
30
+ def is_retryable_error(exc: BaseException) -> bool:
31
+ """Return ``True`` if *exc* is an HTTP error with a retryable status code."""
32
+ return isinstance(exc, HTTPStatusError) and exc.response.status_code in RETRYABLE_STATUS_CODES
33
+
34
+
35
+ class _RetryAfterWait:
36
+ """Tenacity wait strategy that honours the ``Retry-After`` header.
37
+
38
+ If the last exception is a 429 with a ``Retry-After`` header the wait
39
+ time will be *at least* the value specified by the server. Otherwise
40
+ it falls back to zero so a chained exponential strategy takes over.
41
+ """
42
+
43
+ def __call__(self, retry_state: RetryCallState) -> float:
44
+ exc = retry_state.outcome and retry_state.outcome.exception()
45
+ if isinstance(exc, HTTPStatusError) and exc.response.status_code == 429:
46
+ retry_after = exc.response.headers.get("Retry-After")
47
+ if retry_after is not None:
48
+ try:
49
+ wait = float(retry_after)
50
+ logger.info(
51
+ "Server returned Retry-After: %.2fs — will wait at least that long",
52
+ wait,
53
+ )
54
+ return wait
55
+ except (ValueError, OverflowError):
56
+ pass
57
+ return 0.0
58
+
59
+
60
+ def _build_wait(settings: BudaSettings) -> WaitBaseT:
61
+ """Build a combined wait strategy: Retry-After header OR exponential backoff."""
62
+ retry_after: Any = _RetryAfterWait()
63
+ exponential: Any = wait_exponential(
64
+ min=settings.retry_min_wait,
65
+ max=settings.retry_max_wait,
66
+ exp_base=settings.retry_exponential_base,
67
+ )
68
+ # For each attempt pick the larger of [Retry-After header, exponential backoff].
69
+ return retry_after + exponential
70
+
71
+
72
+ def sync_retry_on_error(fn: Callable[..., Any]) -> Callable[..., Any]:
73
+ """Decorator for synchronous methods that retries on 429 / 500 / 503.
74
+
75
+ Reads retry configuration from ``self._settings`` at call time, so each
76
+ client instance can have its own retry policy.
77
+ """
78
+
79
+ @functools.wraps(fn)
80
+ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
81
+ if not self._settings.retry_enabled:
82
+ return fn(self, *args, **kwargs)
83
+ retrying = Retrying(
84
+ retry=retry_if_exception(is_retryable_error),
85
+ stop=stop_after_attempt(self._settings.retry_max_attempts),
86
+ wait=_build_wait(self._settings),
87
+ before_sleep=before_sleep_log(logger, logging.WARNING),
88
+ reraise=True,
89
+ )
90
+ return retrying(fn, self, *args, **kwargs)
91
+
92
+ return wrapper
93
+
94
+
95
+ def async_retry_on_error(fn: Callable[..., Any]) -> Callable[..., Any]:
96
+ """Decorator for async methods that retries on 429 / 500 / 503.
97
+
98
+ Same semantics as :func:`sync_retry_on_error` but for coroutines.
99
+ """
100
+
101
+ @functools.wraps(fn)
102
+ async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
103
+ if not self._settings.retry_enabled:
104
+ return await fn(self, *args, **kwargs)
105
+ retrying = AsyncRetrying(
106
+ retry=retry_if_exception(is_retryable_error),
107
+ stop=stop_after_attempt(self._settings.retry_max_attempts),
108
+ wait=_build_wait(self._settings),
109
+ before_sleep=before_sleep_log(logger, logging.WARNING),
110
+ reraise=True,
111
+ )
112
+ return await retrying(fn, self, *args, **kwargs)
113
+
114
+ return wrapper
buda/core/settings.py ADDED
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ from pydantic import BeforeValidator, Field, HttpUrl, WebsocketUrl
6
+ from pydantic.dataclasses import dataclass
7
+
8
+ BaseUrl = Annotated[str, BeforeValidator(lambda v: HttpUrl(v).encoded_string())]
9
+
10
+
11
+ @dataclass(frozen=True, kw_only=True, slots=True)
12
+ class BudaSettings:
13
+ """Settings for Buda API clients."""
14
+
15
+ # REST API Settings
16
+ base_url: BaseUrl = Field(
17
+ default="https://www.buda.com/api/v2",
18
+ description="Base URL for the Buda API",
19
+ )
20
+ timeout: float | int = Field(
21
+ default=10.0,
22
+ union_mode="left_to_right",
23
+ description="Timeout for API requests in seconds",
24
+ )
25
+
26
+ # REST API - Retry Settings (tenacity)
27
+ retry_enabled: bool = Field(
28
+ default=True,
29
+ description="Enable automatic retry for 429, 500, and 503 HTTP errors",
30
+ )
31
+ retry_max_attempts: int = Field(
32
+ default=3,
33
+ description="Maximum number of retry attempts",
34
+ )
35
+ retry_min_wait: float = Field(
36
+ default=1.0,
37
+ description="Minimum wait time between retries in seconds",
38
+ )
39
+ retry_max_wait: float = Field(
40
+ default=30.0,
41
+ description="Maximum wait time between retries in seconds",
42
+ )
43
+ retry_exponential_base: float = Field(
44
+ default=2.0,
45
+ description="Base for exponential backoff between retries",
46
+ )
47
+
48
+ # REST API - Rate Limit Settings
49
+ rate_limit_enabled: bool = Field(
50
+ default=True,
51
+ description="Enable proactive rate limiting",
52
+ )
53
+ rate_limit_per_second: int = Field(
54
+ default=20,
55
+ description="Maximum requests per second (shared across auth/unauth)",
56
+ )
57
+ rate_limit_auth_per_minute: int = Field(
58
+ default=375,
59
+ description="Maximum authenticated requests per minute (per API key)",
60
+ )
61
+ rate_limit_unauth_per_minute: int = Field(
62
+ default=120,
63
+ description="Maximum unauthenticated requests per minute (per IP)",
64
+ )
65
+
66
+ # WebSocket API Settings
67
+ base_uri: WebsocketUrl = Field(
68
+ default=WebsocketUrl("wss://realtime.buda.com"),
69
+ description="Base URI for the Buda WebSocket API",
70
+ )
71
+ open_timeout: float | None = Field(
72
+ default=10.0,
73
+ description="Timeout for opening WebSocket connections in seconds",
74
+ )
75
+ ping_interval: float | None = Field(
76
+ default=10.0,
77
+ description="Interval for sending WebSocket pings in seconds",
78
+ )
79
+ ping_timeout: float | None = Field(
80
+ default=20.0,
81
+ description="Timeout for WebSocket pings in seconds",
82
+ )
83
+ close_timeout: float | None = Field(
84
+ default=10.0,
85
+ description="Timeout for closing WebSocket connections in seconds",
86
+ )
87
+
88
+ # Common Settings
89
+ user_agent: str = Field(
90
+ default="python-buda-client/0.1.0",
91
+ description="User agent for the Buda API client",
92
+ )
93
+
94
+ @property
95
+ def headers(self) -> dict[str, str]:
96
+ """Default headers for API requests."""
97
+ return {
98
+ "User-Agent": self.user_agent,
99
+ }
buda/rest/__init__.py ADDED
File without changes
File without changes