bitvavo-api-upgraded 4.1.0__tar.gz → 4.1.2__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.2}/PKG-INFO +5 -4
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.2}/README.md +4 -3
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.2}/pyproject.toml +6 -3
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.2}/src/bitvavo_api_upgraded/dataframe_utils.py +1 -1
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/__init__.py +9 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/adapters/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/adapters/returns_adapter.py +362 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/auth/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/auth/rate_limit.py +104 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/auth/signing.py +33 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/core/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/core/errors.py +17 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/core/model_preferences.py +51 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/core/private_models.py +886 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/core/public_models.py +1087 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/core/settings.py +52 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/core/types.py +11 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/core/validation_helpers.py +90 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/df/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/df/convert.py +86 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/endpoints/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/endpoints/common.py +88 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/endpoints/private.py +1232 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/endpoints/public.py +748 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/facade.py +66 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/py.typed +0 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/schemas/__init__.py +50 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/schemas/private_schemas.py +191 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/schemas/public_schemas.py +149 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/transport/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/transport/http.py +159 -0
- bitvavo_api_upgraded-4.1.2/src/bitvavo_client/ws/__init__.py +1 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.2}/src/bitvavo_api_upgraded/__init__.py +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.2}/src/bitvavo_api_upgraded/bitvavo.py +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.2}/src/bitvavo_api_upgraded/helper_funcs.py +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.2}/src/bitvavo_api_upgraded/py.typed +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.2}/src/bitvavo_api_upgraded/settings.py +0 -0
- {bitvavo_api_upgraded-4.1.0 → bitvavo_api_upgraded-4.1.2}/src/bitvavo_api_upgraded/type_aliases.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: bitvavo-api-upgraded
|
3
|
-
Version: 4.1.
|
3
|
+
Version: 4.1.2
|
4
4
|
Summary: A unit-tested fork of the Bitvavo API
|
5
5
|
Author: Bitvavo BV (original code), NostraDavid
|
6
6
|
Author-email: NostraDavid <55331731+NostraDavid@users.noreply.github.com>
|
@@ -301,7 +301,7 @@ client = BitvavoClient(preferred_model=ModelPreference.PYDANTIC)
|
|
301
301
|
result = client.public.time() # Returns: ServerTime(time=1609459200000)
|
302
302
|
|
303
303
|
# Option 3: DataFrame format (pandas, polars, etc.)
|
304
|
-
client = BitvavoClient(preferred_model=ModelPreference.
|
304
|
+
client = BitvavoClient(preferred_model=ModelPreference.POLARS)
|
305
305
|
result = client.public.markets() # Returns: polars.DataFrame with market data
|
306
306
|
```
|
307
307
|
|
@@ -314,9 +314,10 @@ client = BitvavoClient(preferred_model=ModelPreference.RAW)
|
|
314
314
|
# Get raw dict (uses default)
|
315
315
|
raw_data = client.public.markets()
|
316
316
|
|
317
|
-
# Override to get DataFrame for this request
|
317
|
+
# Override to get Polars DataFrame for this request
|
318
318
|
import polars as pl
|
319
|
-
|
319
|
+
from bitvavo_client.core.model_preferences import ModelPreference
|
320
|
+
df_data = client.public.markets(model=ModelPreference.POLARS)
|
320
321
|
|
321
322
|
# Override to get Pydantic model
|
322
323
|
from bitvavo_client.core.public_models import Markets
|
@@ -237,7 +237,7 @@ client = BitvavoClient(preferred_model=ModelPreference.PYDANTIC)
|
|
237
237
|
result = client.public.time() # Returns: ServerTime(time=1609459200000)
|
238
238
|
|
239
239
|
# Option 3: DataFrame format (pandas, polars, etc.)
|
240
|
-
client = BitvavoClient(preferred_model=ModelPreference.
|
240
|
+
client = BitvavoClient(preferred_model=ModelPreference.POLARS)
|
241
241
|
result = client.public.markets() # Returns: polars.DataFrame with market data
|
242
242
|
```
|
243
243
|
|
@@ -250,9 +250,10 @@ client = BitvavoClient(preferred_model=ModelPreference.RAW)
|
|
250
250
|
# Get raw dict (uses default)
|
251
251
|
raw_data = client.public.markets()
|
252
252
|
|
253
|
-
# Override to get DataFrame for this request
|
253
|
+
# Override to get Polars DataFrame for this request
|
254
254
|
import polars as pl
|
255
|
-
|
255
|
+
from bitvavo_client.core.model_preferences import ModelPreference
|
256
|
+
df_data = client.public.markets(model=ModelPreference.POLARS)
|
256
257
|
|
257
258
|
# Override to get Pydantic model
|
258
259
|
from bitvavo_client.core.public_models import Markets
|
@@ -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.2"
|
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"
|
@@ -86,7 +89,7 @@ dev-dependencies = [
|
|
86
89
|
"mdformat>=0.7.11", # for formatting markdown (.md) documents
|
87
90
|
"mkdocs>=1.2.3", # for markdown documentation
|
88
91
|
"mypy>=1.13", # amazing linter
|
89
|
-
"narwhals>=2.0.0",
|
92
|
+
"narwhals[dask,duckdb,pandas,polars,pyarrow]>=2.0.0", # unified dataframe API with all integrations for testing
|
90
93
|
"pandas-stubs>=2.2.2.0", # pandas typing for mypy
|
91
94
|
"pandas>=2.0.0", # pandas support for testing
|
92
95
|
"polars>=1.0.0", # polars support for testing
|
@@ -105,7 +108,7 @@ dev-dependencies = [
|
|
105
108
|
]
|
106
109
|
|
107
110
|
[tool.bumpversion]
|
108
|
-
current_version = "4.1.
|
111
|
+
current_version = "4.1.2"
|
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,362 @@
|
|
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
|
+
# Check if model is a polars DataFrame specifically by checking module and class name
|
289
|
+
if hasattr(model, "__name__") and "polars" in str(model.__module__) and "DataFrame" in str(model):
|
290
|
+
parsed = model(data, schema=schema, strict=False) # type: ignore[arg-type]
|
291
|
+
else:
|
292
|
+
parsed = model(data, schema=schema) # type: ignore[arg-type]
|
293
|
+
except (ImportError, AttributeError):
|
294
|
+
parsed = model(data, schema=schema) # type: ignore[arg-type]
|
295
|
+
return Success(parsed)
|
296
|
+
except Exception as exc: # noqa: BLE001
|
297
|
+
# If the payload looks like a Bitvavo error, map it so callers get a structured error.
|
298
|
+
if isinstance(data, dict) and any(key in data for key in ["errorCode", "error", "message"]):
|
299
|
+
return Failure(_map_error(resp))
|
300
|
+
|
301
|
+
# Enhanced error message for DataFrame schema mismatches
|
302
|
+
enhanced_error = _enhance_dataframe_error(exc, data, schema, model)
|
303
|
+
|
304
|
+
logger.warning(
|
305
|
+
"model_validation-failed",
|
306
|
+
error=enhanced_error,
|
307
|
+
exception_type=type(exc).__name__,
|
308
|
+
payload=data,
|
309
|
+
)
|
310
|
+
return Failure(_validation_failure(enhanced_error, data if isinstance(data, dict) else {"raw": data}))
|
311
|
+
|
312
|
+
|
313
|
+
# ---------------------------------------------------------------------------
|
314
|
+
# Sync request wrappers returning Result
|
315
|
+
# ---------------------------------------------------------------------------
|
316
|
+
|
317
|
+
|
318
|
+
def get_json_result(
|
319
|
+
client: httpx.Client,
|
320
|
+
path: str,
|
321
|
+
*,
|
322
|
+
model: type[T],
|
323
|
+
) -> Result[T | dict[str, Any], BitvavoError]:
|
324
|
+
url = f"{settings.base_url.rstrip('/')}/{path.lstrip('/')}"
|
325
|
+
try:
|
326
|
+
resp = client.get(url, timeout=settings.timeout_seconds)
|
327
|
+
except httpx.HTTPError as exc:
|
328
|
+
logger.error("HTTP request failed: %s", exc)
|
329
|
+
return Failure(
|
330
|
+
BitvavoError(
|
331
|
+
http_status=0,
|
332
|
+
error_code=-1,
|
333
|
+
reason="Transport error",
|
334
|
+
message=str(exc),
|
335
|
+
raw={},
|
336
|
+
),
|
337
|
+
)
|
338
|
+
return decode_response_result(resp, model=model)
|
339
|
+
|
340
|
+
|
341
|
+
def post_json_result(
|
342
|
+
client: httpx.Client,
|
343
|
+
path: str,
|
344
|
+
payload: dict[str, Any] | None = None,
|
345
|
+
*,
|
346
|
+
model: type[T] | None = None,
|
347
|
+
) -> Result[T | dict[str, Any], BitvavoError]:
|
348
|
+
url = f"{settings.base_url.rstrip('/')}/{path.lstrip('/')}"
|
349
|
+
try:
|
350
|
+
resp = client.post(url, json=payload or {}, timeout=settings.timeout_seconds)
|
351
|
+
except httpx.HTTPError as exc:
|
352
|
+
logger.error("HTTP request failed: %s", exc)
|
353
|
+
return Failure(
|
354
|
+
BitvavoError(
|
355
|
+
http_status=0,
|
356
|
+
error_code=-1,
|
357
|
+
reason="Transport error",
|
358
|
+
message=str(exc),
|
359
|
+
raw={"payload": payload or {}},
|
360
|
+
),
|
361
|
+
)
|
362
|
+
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."""
|