bitvavo-api-upgraded 4.1.0__tar.gz → 4.1.1__tar.gz
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.
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.1}/PKG-INFO +1 -1
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.1}/pyproject.toml +5 -2
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/__init__.py +9 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/adapters/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/adapters/returns_adapter.py +363 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/auth/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/auth/rate_limit.py +104 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/auth/signing.py +33 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/core/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/core/errors.py +17 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/core/model_preferences.py +33 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/core/private_models.py +886 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/core/public_models.py +1087 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/core/settings.py +52 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/core/types.py +11 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/core/validation_helpers.py +90 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/df/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/df/convert.py +86 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/endpoints/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/endpoints/common.py +88 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/endpoints/private.py +1090 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/endpoints/public.py +658 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/facade.py +66 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/py.typed +0 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/schemas/__init__.py +50 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/schemas/private_schemas.py +191 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/schemas/public_schemas.py +149 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/transport/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/transport/http.py +159 -0
- bitvavo_api_upgraded-4.1.1/src/bitvavo_client/ws/__init__.py +1 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.1}/README.md +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.1}/src/bitvavo_api_upgraded/__init__.py +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.1}/src/bitvavo_api_upgraded/bitvavo.py +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.1}/src/bitvavo_api_upgraded/dataframe_utils.py +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.1}/src/bitvavo_api_upgraded/helper_funcs.py +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.1}/src/bitvavo_api_upgraded/py.typed +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.1}/src/bitvavo_api_upgraded/settings.py +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.1}/src/bitvavo_api_upgraded/type_aliases.py +0 -0
@@ -6,7 +6,7 @@ build-backend = "uv_build"
|
|
6
6
|
# https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
|
7
7
|
[project]
|
8
8
|
name = "bitvavo-api-upgraded"
|
9
|
-
version = "4.1.
|
9
|
+
version = "4.1.1"
|
10
10
|
description = "A unit-tested fork of the Bitvavo API"
|
11
11
|
readme = "README.md"
|
12
12
|
requires-python = ">=3.10"
|
@@ -45,6 +45,9 @@ dependencies = [
|
|
45
45
|
"websocket-client >=1.2", # something something websocket
|
46
46
|
]
|
47
47
|
|
48
|
+
[tool.uv.build-backend]
|
49
|
+
module-name = ["bitvavo_api_upgraded", "bitvavo_client"]
|
50
|
+
|
48
51
|
[[tool.uv.index]]
|
49
52
|
# Optional name for the index.
|
50
53
|
name = "nvidia"
|
@@ -105,7 +108,7 @@ dev-dependencies = [
|
|
105
108
|
]
|
106
109
|
|
107
110
|
[tool.bumpversion]
|
108
|
-
current_version = "4.1.
|
111
|
+
current_version = "4.1.1"
|
109
112
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
110
113
|
serialize = ["{major}.{minor}.{patch}"]
|
111
114
|
search = "{current_version}"
|
@@ -0,0 +1 @@
|
|
1
|
+
"""Optional adapters for alternative result-handling styles."""
|
@@ -0,0 +1,363 @@
|
|
1
|
+
"""Adapter utilities that return `returns.result.Result` types.
|
2
|
+
|
3
|
+
This integrates clean Result-based decoding and lightweight request helpers on top
|
4
|
+
of httpx, mapping Bitvavo errors to a structured model.
|
5
|
+
|
6
|
+
The adapter is optional and doesn't alter the existing facade. Users who prefer
|
7
|
+
functional error handling can import and use these utilities directly.
|
8
|
+
"""
|
9
|
+
|
10
|
+
from __future__ import annotations
|
11
|
+
|
12
|
+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
|
13
|
+
|
14
|
+
import httpx
|
15
|
+
from pydantic import BaseModel, Field
|
16
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
17
|
+
from returns.result import Failure, Result, Success
|
18
|
+
from structlog.stdlib import get_logger
|
19
|
+
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
21
|
+
from collections.abc import Mapping
|
22
|
+
|
23
|
+
T = TypeVar("T")
|
24
|
+
|
25
|
+
logger = get_logger("bitvavo.adapter")
|
26
|
+
|
27
|
+
|
28
|
+
# ---------------------------------------------------------------------------
|
29
|
+
# Settings (pydantic v2)
|
30
|
+
# ---------------------------------------------------------------------------
|
31
|
+
|
32
|
+
|
33
|
+
class BitvavoReturnsSettings(BaseSettings):
|
34
|
+
"""Settings for the returns-based adapter.
|
35
|
+
|
36
|
+
These are intentionally separate from the core BitvavoSettings to avoid
|
37
|
+
coupling and to let users override independently via environment vars.
|
38
|
+
"""
|
39
|
+
|
40
|
+
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
41
|
+
env_prefix="BITVAVO_",
|
42
|
+
extra="ignore",
|
43
|
+
env_file=".env",
|
44
|
+
env_file_encoding="utf-8",
|
45
|
+
)
|
46
|
+
|
47
|
+
base_url: str = "https://api.bitvavo.com/v2"
|
48
|
+
timeout_seconds: float = 10.0
|
49
|
+
|
50
|
+
|
51
|
+
settings = BitvavoReturnsSettings()
|
52
|
+
|
53
|
+
|
54
|
+
# ---------------------------------------------------------------------------
|
55
|
+
# Domain models
|
56
|
+
# ---------------------------------------------------------------------------
|
57
|
+
|
58
|
+
|
59
|
+
class BitvavoErrorPayload(BaseModel):
|
60
|
+
"""Typical Bitvavo error payload.
|
61
|
+
|
62
|
+
Example:
|
63
|
+
{"errorCode": 205, "error": "Invalid parameter value."}
|
64
|
+
"""
|
65
|
+
|
66
|
+
error_code: int = Field(alias="errorCode")
|
67
|
+
error: str
|
68
|
+
|
69
|
+
|
70
|
+
class BitvavoError(BaseModel):
|
71
|
+
http_status: int
|
72
|
+
error_code: int
|
73
|
+
reason: str
|
74
|
+
message: str
|
75
|
+
raw: dict[str, Any]
|
76
|
+
|
77
|
+
|
78
|
+
# ---------------------------------------------------------------------------
|
79
|
+
# Error code directory (from Bitvavo docs)
|
80
|
+
# ---------------------------------------------------------------------------
|
81
|
+
|
82
|
+
_BITVAVO_ERROR_REASONS: dict[int, dict[int, str]] = {
|
83
|
+
400: {
|
84
|
+
102: "The JSON object you sent is invalid.",
|
85
|
+
200: "Path parameters are not accepted for this endpoint.",
|
86
|
+
201: "Body parameters are not accepted for this endpoint.",
|
87
|
+
202: "A parameter is not supported for this orderType.",
|
88
|
+
203: "Missing or incompatible parameters in your request.",
|
89
|
+
204: "You used a parameter that is not valid for this endpoint.",
|
90
|
+
205: "Invalid parameter value.",
|
91
|
+
206: "Incompatible parameters in your call.",
|
92
|
+
210: "amount exceeds the maximum allowed for the market.",
|
93
|
+
211: "price exceeds the maximum allowed.",
|
94
|
+
212: "amount is lower than the minimum allowed for the market.",
|
95
|
+
213: "price is too low.",
|
96
|
+
214: "price has too many significant digits.",
|
97
|
+
215: "price has more than 15 decimal places.",
|
98
|
+
216: "Insufficient balance to perform this operation.",
|
99
|
+
217: "amountQuote is lower than the minimum allowed for the market.",
|
100
|
+
218: "triggerAmount has too many significant digits.",
|
101
|
+
219: "The market is no longer listed on Bitvavo.",
|
102
|
+
220: "clientOrderId conflict within the market.",
|
103
|
+
231: "timeInForce must be set to GTC when markets are paused.",
|
104
|
+
232: "Changes required for a successful update to your order.",
|
105
|
+
234: "Cannot update a market order type.",
|
106
|
+
235: "Maximum of 100 open orders per market exceeded.",
|
107
|
+
236: "Only one of amount, amountRemaining, or amountQuote is allowed.",
|
108
|
+
237: "Required parameters missing for stopLoss order type.",
|
109
|
+
238: "Required parameters missing for stopLossLimit order type.",
|
110
|
+
239: "Cannot switch between amount and amountQuote during an update.",
|
111
|
+
401: "Deposits for this asset are not available.",
|
112
|
+
402: "Verify your identity to deposit or withdraw assets.",
|
113
|
+
403: "Verify your phone number to deposit or withdraw assets.",
|
114
|
+
404: "Could not complete the operation due to an internal error.",
|
115
|
+
405: "Withdrawal not allowed during the cooldown period.",
|
116
|
+
406: "amount is below the minimum allowed value.",
|
117
|
+
407: "Internal transfer is not possible.",
|
118
|
+
408: "Insufficient balance to perform this operation.",
|
119
|
+
409: "Verify your bank account and try again.",
|
120
|
+
410: "Withdrawals for this asset are not available.",
|
121
|
+
411: "You cannot transfer assets to yourself.",
|
122
|
+
412: "Error during deposit or withdrawal.",
|
123
|
+
413: "IP address not in your whitelist.",
|
124
|
+
414: "Cannot withdraw assets within 2 minutes of logging in.",
|
125
|
+
422: "Invalid price tick size.",
|
126
|
+
423: "Market halted due to maintenance or other reasons.",
|
127
|
+
424: "Market is in cancelOnly status.",
|
128
|
+
425: "Market is in an auction phase.",
|
129
|
+
426: "Market is in auctionMatching status.",
|
130
|
+
429: "Too many decimal places in a parameter value.",
|
131
|
+
},
|
132
|
+
403: {
|
133
|
+
300: "Authentication required to call this endpoint.",
|
134
|
+
301: "Invalid API key length.",
|
135
|
+
302: "Timestamp for authentication must be in milliseconds.",
|
136
|
+
303: "Access window must be between 100 and 60000 milliseconds.",
|
137
|
+
304: "Request not received within the access window.",
|
138
|
+
305: "API key is not active.",
|
139
|
+
306: "API key activation not confirmed.",
|
140
|
+
307: "IP not whitelisted for this API key.",
|
141
|
+
308: "Invalid signature format.",
|
142
|
+
309: "Invalid signature.",
|
143
|
+
310: "API key lacks trading endpoint permissions.",
|
144
|
+
311: "API key lacks account endpoint permissions.",
|
145
|
+
312: "API key lacks withdrawal permissions.",
|
146
|
+
313: "Invalid Bitvavo session.",
|
147
|
+
316: "Public asset information only via this WebSocket.",
|
148
|
+
317: "Account locked. Contact support.",
|
149
|
+
318: "Account verification required for API use.",
|
150
|
+
319: "Feature unavailable in your region.",
|
151
|
+
320: "Operation forbidden. Please contact support.",
|
152
|
+
},
|
153
|
+
404: {
|
154
|
+
240: "Order not found or no longer active.",
|
155
|
+
415: "Unknown WebSocket action. Verify against the API reference.",
|
156
|
+
},
|
157
|
+
409: {
|
158
|
+
431: "Cannot get market data; market is halted/auction/auctionMatching.",
|
159
|
+
},
|
160
|
+
429: {
|
161
|
+
105: "Rate limit exceeded. Account or IP address blocked temporarily.",
|
162
|
+
112: "Rate limit exceeded for WebSocket requests per second.",
|
163
|
+
},
|
164
|
+
500: {
|
165
|
+
101: "Unknown server error. Operation success uncertain.",
|
166
|
+
400: "Unknown server error. Contact support.",
|
167
|
+
},
|
168
|
+
503: {
|
169
|
+
107: "Bitvavo is overloaded. Retry after 500ms.",
|
170
|
+
108: "Processing issue. Increase execution window or retry after 500ms.",
|
171
|
+
109: "Timeout. Operation success uncertain.",
|
172
|
+
111: "Matching engine temporarily unavailable.",
|
173
|
+
419: "The server is unavailable.",
|
174
|
+
430: "Connection timed out due to no new market data events.",
|
175
|
+
},
|
176
|
+
}
|
177
|
+
|
178
|
+
|
179
|
+
# HTTP status helpers
|
180
|
+
HTTP_STATUS_OK_MIN = 200
|
181
|
+
HTTP_STATUS_OK_MAX = 299
|
182
|
+
|
183
|
+
|
184
|
+
# ---------------------------------------------------------------------------
|
185
|
+
# Core decoding → Result
|
186
|
+
# ---------------------------------------------------------------------------
|
187
|
+
|
188
|
+
|
189
|
+
def _json_from_response(resp: httpx.Response) -> dict[str, Any]:
|
190
|
+
try:
|
191
|
+
data = resp.json() # type: ignore[no-any-return]
|
192
|
+
assert isinstance(data, dict), "Expected JSON response to be a dictionary"
|
193
|
+
except ValueError:
|
194
|
+
return {"raw": resp.text}
|
195
|
+
else:
|
196
|
+
return data
|
197
|
+
|
198
|
+
|
199
|
+
def _map_error(resp: httpx.Response) -> BitvavoError:
|
200
|
+
payload = _json_from_response(resp)
|
201
|
+
code = int(payload.get("errorCode", -1))
|
202
|
+
message = str(payload.get("error", payload.get("message", "")) or "")
|
203
|
+
reason_dir = _BITVAVO_ERROR_REASONS.get(resp.status_code, {})
|
204
|
+
reason = reason_dir.get(code, message or "Unknown error")
|
205
|
+
return BitvavoError(
|
206
|
+
http_status=resp.status_code,
|
207
|
+
error_code=code,
|
208
|
+
reason=reason,
|
209
|
+
message=message or reason,
|
210
|
+
raw=payload if isinstance(payload, dict) else {"raw": payload},
|
211
|
+
)
|
212
|
+
|
213
|
+
|
214
|
+
def _validation_failure(reason: str, payload: dict[str, Any]) -> BitvavoError:
|
215
|
+
return BitvavoError(
|
216
|
+
http_status=500,
|
217
|
+
error_code=-1,
|
218
|
+
reason="Model validation failed",
|
219
|
+
message=reason,
|
220
|
+
raw=payload,
|
221
|
+
)
|
222
|
+
|
223
|
+
|
224
|
+
def _enhance_dataframe_error(exc: Exception, data: Any, schema: Mapping[str, object] | None, model: type) -> str:
|
225
|
+
"""Create enhanced error message for DataFrame schema mismatches."""
|
226
|
+
error_msg = str(exc)
|
227
|
+
|
228
|
+
if "column-schema names do not match the data dictionary" not in error_msg:
|
229
|
+
return error_msg
|
230
|
+
|
231
|
+
# Extract actual field names from the data
|
232
|
+
if isinstance(data, dict):
|
233
|
+
actual_fields = list(data.keys())
|
234
|
+
elif isinstance(data, list) and data and isinstance(data[0], dict):
|
235
|
+
actual_fields = list(data[0].keys())
|
236
|
+
else:
|
237
|
+
actual_fields = []
|
238
|
+
|
239
|
+
# Extract expected field names from schema
|
240
|
+
expected_fields = list(schema.keys()) if schema else []
|
241
|
+
|
242
|
+
model_name = getattr(model, "__name__", "DataFrame")
|
243
|
+
return (
|
244
|
+
f"DataFrame schema mismatch for {model_name}:\n"
|
245
|
+
f" Expected fields: {expected_fields}\n"
|
246
|
+
f" Actual fields: {actual_fields}\n"
|
247
|
+
f" Missing fields: {set(expected_fields) - set(actual_fields)}\n"
|
248
|
+
f" Extra fields: {set(actual_fields) - set(expected_fields)}\n"
|
249
|
+
f" Original error: {error_msg}"
|
250
|
+
)
|
251
|
+
|
252
|
+
|
253
|
+
def decode_response_result( # noqa: C901 (complexity)
|
254
|
+
resp: httpx.Response,
|
255
|
+
model: type[T] | None,
|
256
|
+
schema: Mapping[str, object] | None = None,
|
257
|
+
) -> Result[T | Any, BitvavoError]:
|
258
|
+
if not (HTTP_STATUS_OK_MIN <= resp.status_code <= HTTP_STATUS_OK_MAX):
|
259
|
+
return Failure(_map_error(resp))
|
260
|
+
|
261
|
+
try:
|
262
|
+
data = resp.json() # type: ignore[no-any-return]
|
263
|
+
except ValueError:
|
264
|
+
data: Any = {"raw": resp.text}
|
265
|
+
|
266
|
+
if model is Any:
|
267
|
+
# data is invalid (an error of sorts)
|
268
|
+
if isinstance(data, dict) and any(key in data for key in ["errorCode", "error", "message"]):
|
269
|
+
return Failure(_map_error(resp))
|
270
|
+
|
271
|
+
# return the raw (valid) JSON
|
272
|
+
return Success(data)
|
273
|
+
|
274
|
+
assert model is not None, "Model must be provided or set to Any"
|
275
|
+
assert isinstance(data, (dict, list)), "Expected JSON response to be a dictionary or list"
|
276
|
+
|
277
|
+
try:
|
278
|
+
# Support pydantic model classes/instances and common DataFrame libraries
|
279
|
+
# (pandas / polars). Also fall back to calling a constructor if provided.
|
280
|
+
if (isinstance(model, type) and issubclass(model, BaseModel)) or isinstance(model, BaseModel):
|
281
|
+
parsed = model.model_validate(data)
|
282
|
+
elif schema is None:
|
283
|
+
parsed = model(data) # type: ignore[arg-type]
|
284
|
+
else:
|
285
|
+
# I don't like the complexity of this piece, but it's needed because the data from ticker_book may return an
|
286
|
+
# int when it should be a float... Why is their DB such a damned mess? Fuck me, man...
|
287
|
+
try:
|
288
|
+
import polars as pl # noqa: PLC0415
|
289
|
+
|
290
|
+
if model is pl.DataFrame:
|
291
|
+
parsed = model(data, schema=schema, strict=False) # type: ignore[arg-type]
|
292
|
+
else:
|
293
|
+
parsed = model(data, schema=schema) # type: ignore[arg-type]
|
294
|
+
except ImportError:
|
295
|
+
parsed = model(data, schema=schema) # type: ignore[arg-type]
|
296
|
+
return Success(parsed)
|
297
|
+
except Exception as exc: # noqa: BLE001
|
298
|
+
# If the payload looks like a Bitvavo error, map it so callers get a structured error.
|
299
|
+
if isinstance(data, dict) and any(key in data for key in ["errorCode", "error", "message"]):
|
300
|
+
return Failure(_map_error(resp))
|
301
|
+
|
302
|
+
# Enhanced error message for DataFrame schema mismatches
|
303
|
+
enhanced_error = _enhance_dataframe_error(exc, data, schema, model)
|
304
|
+
|
305
|
+
logger.warning(
|
306
|
+
"model_validation-failed",
|
307
|
+
error=enhanced_error,
|
308
|
+
exception_type=type(exc).__name__,
|
309
|
+
payload=data,
|
310
|
+
)
|
311
|
+
return Failure(_validation_failure(enhanced_error, data if isinstance(data, dict) else {"raw": data}))
|
312
|
+
|
313
|
+
|
314
|
+
# ---------------------------------------------------------------------------
|
315
|
+
# Sync request wrappers returning Result
|
316
|
+
# ---------------------------------------------------------------------------
|
317
|
+
|
318
|
+
|
319
|
+
def get_json_result(
|
320
|
+
client: httpx.Client,
|
321
|
+
path: str,
|
322
|
+
*,
|
323
|
+
model: type[T],
|
324
|
+
) -> Result[T | dict[str, Any], BitvavoError]:
|
325
|
+
url = f"{settings.base_url.rstrip('/')}/{path.lstrip('/')}"
|
326
|
+
try:
|
327
|
+
resp = client.get(url, timeout=settings.timeout_seconds)
|
328
|
+
except httpx.HTTPError as exc:
|
329
|
+
logger.error("HTTP request failed: %s", exc)
|
330
|
+
return Failure(
|
331
|
+
BitvavoError(
|
332
|
+
http_status=0,
|
333
|
+
error_code=-1,
|
334
|
+
reason="Transport error",
|
335
|
+
message=str(exc),
|
336
|
+
raw={},
|
337
|
+
),
|
338
|
+
)
|
339
|
+
return decode_response_result(resp, model=model)
|
340
|
+
|
341
|
+
|
342
|
+
def post_json_result(
|
343
|
+
client: httpx.Client,
|
344
|
+
path: str,
|
345
|
+
payload: dict[str, Any] | None = None,
|
346
|
+
*,
|
347
|
+
model: type[T] | None = None,
|
348
|
+
) -> Result[T | dict[str, Any], BitvavoError]:
|
349
|
+
url = f"{settings.base_url.rstrip('/')}/{path.lstrip('/')}"
|
350
|
+
try:
|
351
|
+
resp = client.post(url, json=payload or {}, timeout=settings.timeout_seconds)
|
352
|
+
except httpx.HTTPError as exc:
|
353
|
+
logger.error("HTTP request failed: %s", exc)
|
354
|
+
return Failure(
|
355
|
+
BitvavoError(
|
356
|
+
http_status=0,
|
357
|
+
error_code=-1,
|
358
|
+
reason="Transport error",
|
359
|
+
message=str(exc),
|
360
|
+
raw={"payload": payload or {}},
|
361
|
+
),
|
362
|
+
)
|
363
|
+
return decode_response_result(resp, model=model)
|
@@ -0,0 +1 @@
|
|
1
|
+
"""Auth modules for bitvavo_client."""
|
@@ -0,0 +1,104 @@
|
|
1
|
+
"""Rate limiting manager for Bitvavo API."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import time
|
6
|
+
|
7
|
+
|
8
|
+
class RateLimitManager:
|
9
|
+
"""Manages rate limiting for multiple API keys and keyless requests.
|
10
|
+
|
11
|
+
Each API key index has its own rate limit state. Index -1 is reserved
|
12
|
+
for keyless requests.
|
13
|
+
"""
|
14
|
+
|
15
|
+
def __init__(self, default_remaining: int, buffer: int) -> None:
|
16
|
+
"""Initialize rate limit manager.
|
17
|
+
|
18
|
+
Args:
|
19
|
+
default_remaining: Default rate limit amount
|
20
|
+
buffer: Buffer to keep before hitting limit
|
21
|
+
"""
|
22
|
+
self.state: dict[int, dict[str, int]] = {-1: {"remaining": default_remaining, "resetAt": 0}}
|
23
|
+
self.buffer: int = buffer
|
24
|
+
|
25
|
+
def ensure_key(self, idx: int) -> None:
|
26
|
+
"""Ensure a key index exists in the state."""
|
27
|
+
if idx not in self.state:
|
28
|
+
self.state[idx] = {"remaining": self.state[-1]["remaining"], "resetAt": 0}
|
29
|
+
|
30
|
+
def has_budget(self, idx: int, weight: int) -> bool:
|
31
|
+
"""Check if there's enough rate limit budget for a request.
|
32
|
+
|
33
|
+
Args:
|
34
|
+
idx: API key index (-1 for keyless)
|
35
|
+
weight: Weight of the request
|
36
|
+
|
37
|
+
Returns:
|
38
|
+
True if request can be made within rate limits
|
39
|
+
"""
|
40
|
+
self.ensure_key(idx)
|
41
|
+
return (self.state[idx]["remaining"] - weight) >= self.buffer
|
42
|
+
|
43
|
+
def update_from_headers(self, idx: int, headers: dict[str, str]) -> None:
|
44
|
+
"""Update rate limit state from response headers.
|
45
|
+
|
46
|
+
Args:
|
47
|
+
idx: API key index
|
48
|
+
headers: HTTP response headers
|
49
|
+
"""
|
50
|
+
self.ensure_key(idx)
|
51
|
+
|
52
|
+
remaining = headers.get("bitvavo-ratelimit-remaining")
|
53
|
+
reset_at = headers.get("bitvavo-ratelimit-resetat")
|
54
|
+
|
55
|
+
if remaining is not None:
|
56
|
+
self.state[idx]["remaining"] = int(remaining)
|
57
|
+
if reset_at is not None:
|
58
|
+
self.state[idx]["resetAt"] = int(reset_at)
|
59
|
+
|
60
|
+
def update_from_error(self, idx: int, _err: dict[str, object]) -> None:
|
61
|
+
"""Update rate limit state from API error response.
|
62
|
+
|
63
|
+
Args:
|
64
|
+
idx: API key index
|
65
|
+
_err: Error response from API (unused but kept for interface compatibility)
|
66
|
+
"""
|
67
|
+
self.ensure_key(idx)
|
68
|
+
self.state[idx]["remaining"] = 0
|
69
|
+
self.state[idx]["resetAt"] = int(time.time() * 1000) + 60_000
|
70
|
+
|
71
|
+
def sleep_until_reset(self, idx: int) -> None:
|
72
|
+
"""Sleep until rate limit resets for given key index.
|
73
|
+
|
74
|
+
Args:
|
75
|
+
idx: API key index
|
76
|
+
"""
|
77
|
+
self.ensure_key(idx)
|
78
|
+
now = int(time.time() * 1000)
|
79
|
+
ms_left = max(0, self.state[idx]["resetAt"] - now)
|
80
|
+
time.sleep(ms_left / 1000 + 1)
|
81
|
+
|
82
|
+
def get_remaining(self, idx: int) -> int:
|
83
|
+
"""Get remaining rate limit for key index.
|
84
|
+
|
85
|
+
Args:
|
86
|
+
idx: API key index
|
87
|
+
|
88
|
+
Returns:
|
89
|
+
Remaining rate limit count
|
90
|
+
"""
|
91
|
+
self.ensure_key(idx)
|
92
|
+
return self.state[idx]["remaining"]
|
93
|
+
|
94
|
+
def get_reset_at(self, idx: int) -> int:
|
95
|
+
"""Get reset timestamp for key index.
|
96
|
+
|
97
|
+
Args:
|
98
|
+
idx: API key index
|
99
|
+
|
100
|
+
Returns:
|
101
|
+
Reset timestamp in milliseconds
|
102
|
+
"""
|
103
|
+
self.ensure_key(idx)
|
104
|
+
return self.state[idx]["resetAt"]
|
@@ -0,0 +1,33 @@
|
|
1
|
+
"""Signature creation utilities for Bitvavo API authentication."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import hashlib
|
6
|
+
import hmac
|
7
|
+
import json
|
8
|
+
from typing import TYPE_CHECKING
|
9
|
+
|
10
|
+
if TYPE_CHECKING: # pragma: no cover
|
11
|
+
from bitvavo_client.core.types import AnyDict
|
12
|
+
|
13
|
+
|
14
|
+
def create_signature(timestamp: int, method: str, url: str, body: AnyDict | None, api_secret: str) -> str:
|
15
|
+
"""Create HMAC-SHA256 signature for Bitvavo API authentication.
|
16
|
+
|
17
|
+
Args:
|
18
|
+
timestamp: Unix timestamp in milliseconds
|
19
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
20
|
+
url: API endpoint URL without base URL
|
21
|
+
body: Request body as dictionary (optional)
|
22
|
+
api_secret: API secret key
|
23
|
+
|
24
|
+
Returns:
|
25
|
+
HMAC-SHA256 signature as hexadecimal string
|
26
|
+
"""
|
27
|
+
string = f"{timestamp}{method}/v2{url}"
|
28
|
+
if body is not None and len(body) > 0:
|
29
|
+
string += json.dumps(body, separators=(",", ":"))
|
30
|
+
|
31
|
+
signature = hmac.new(api_secret.encode("utf-8"), string.encode("utf-8"), hashlib.sha256).hexdigest()
|
32
|
+
|
33
|
+
return signature
|
@@ -0,0 +1 @@
|
|
1
|
+
"""Core modules for bitvavo_client."""
|
@@ -0,0 +1,17 @@
|
|
1
|
+
"""Error definitions for bitvavo_client."""
|
2
|
+
|
3
|
+
|
4
|
+
class BitvavoError(Exception): # pragma: no cover
|
5
|
+
"""Base exception for Bitvavo API errors."""
|
6
|
+
|
7
|
+
|
8
|
+
class RateLimitError(BitvavoError): # pragma: no cover
|
9
|
+
"""Raised when rate limit is exceeded."""
|
10
|
+
|
11
|
+
|
12
|
+
class AuthenticationError(BitvavoError): # pragma: no cover
|
13
|
+
"""Raised when authentication fails."""
|
14
|
+
|
15
|
+
|
16
|
+
class NetworkError(BitvavoError): # pragma: no cover
|
17
|
+
"""Raised when network operations fail."""
|
@@ -0,0 +1,33 @@
|
|
1
|
+
"""Model preference enum for API endpoints."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import sys
|
6
|
+
from enum import Enum
|
7
|
+
|
8
|
+
if sys.version_info >= (3, 11):
|
9
|
+
from enum import StrEnum
|
10
|
+
else:
|
11
|
+
|
12
|
+
class StrEnum(str, Enum):
|
13
|
+
"""Compatibility StrEnum for Python < 3.11."""
|
14
|
+
|
15
|
+
|
16
|
+
class ModelPreference(StrEnum):
|
17
|
+
"""Enumeration of available model preferences for API responses.
|
18
|
+
|
19
|
+
This enum allows users to specify their preferred response format across
|
20
|
+
all API methods without having to pass model parameters to each call.
|
21
|
+
"""
|
22
|
+
|
23
|
+
# Return raw Python data structures (dict/list)
|
24
|
+
RAW = "raw"
|
25
|
+
|
26
|
+
# Return Polars DataFrame (requires schema)
|
27
|
+
DATAFRAME = "dataframe"
|
28
|
+
|
29
|
+
# Return appropriate Pydantic model for each endpoint
|
30
|
+
PYDANTIC = "pydantic"
|
31
|
+
|
32
|
+
# Let each method use its own default (legacy behavior)
|
33
|
+
AUTO = "auto"
|