bitvavo-api-upgraded 4.1.2__py3-none-any.whl → 4.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: bitvavo-api-upgraded
3
- Version: 4.1.2
3
+ Version: 4.2.1
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>
@@ -9,7 +9,7 @@ bitvavo_client/__init__.py,sha256=YXTBdP6fBREV34VeTqS_gkjfzIoHv5uSYhbqSUEeAVU,20
9
9
  bitvavo_client/adapters/__init__.py,sha256=9YVjMhNiAN6K1x7N0UvAXMQwuhOFy4W6Edrba1hW8KI,64
10
10
  bitvavo_client/adapters/returns_adapter.py,sha256=3HSAPw6HB9GCS8AbKmeidURpZXnvMZqkvalOu6JhBv0,14195
11
11
  bitvavo_client/auth/__init__.py,sha256=bjWu5WCKNNnNoLcVU290tKBml9M5afmcxaU_KrkisSQ,39
12
- bitvavo_client/auth/rate_limit.py,sha256=09IjdeTU9GZlszFBmiM6RynE50NwIICyy-8oM6HtX-k,3229
12
+ bitvavo_client/auth/rate_limit.py,sha256=EfZtt4nxzu8D3Ea2ElYadrAvEYEt_Wlm60P8u-zVMvs,4391
13
13
  bitvavo_client/auth/signing.py,sha256=DJrI1R1SLKjl276opj9hN4RrKIgsMhxsSEDA8b7T04I,1037
14
14
  bitvavo_client/core/__init__.py,sha256=WqjaU9Ut5JdZwn4tsR1vDdrSfMjEJred3im6fvWpalc,39
15
15
  bitvavo_client/core/errors.py,sha256=jWHHQKqkkhpHS9TeKlccl7wuyuRrq0H_PGZ0bl6sbW4,460
@@ -22,17 +22,18 @@ bitvavo_client/core/validation_helpers.py,sha256=2KeviuRXFiq4pgttHjC9q8gcrYmtLos
22
22
  bitvavo_client/df/__init__.py,sha256=1ui3dsRhDvy0NOoOi4zj5gR48u828Au9K_qtH9S1hIo,44
23
23
  bitvavo_client/df/convert.py,sha256=bf46QYmyB8o4KFdwUOfaxLV3_Qp29gq0L6hk3Qj-0pI,2522
24
24
  bitvavo_client/endpoints/__init__.py,sha256=X1e_Hn6xN7FBBLgKOpjdfIbKiXSp8f4gYSn8wRcn4ro,43
25
+ bitvavo_client/endpoints/base.py,sha256=EyhxXJ2YVuXJeaIwgvF7VAzY-qVZ9maA282Xj0lSyIc,11977
25
26
  bitvavo_client/endpoints/common.py,sha256=fc4gNNZ2zGMJJwkbHexNz6qMDLjl6dalQFGDXWmBo2E,2413
26
- bitvavo_client/endpoints/private.py,sha256=dVpDegr_o7-Lsq4HXgPGLas2DcVB8CndfXy7GqHK8AQ,48101
27
- bitvavo_client/endpoints/public.py,sha256=gDMv1_4RxDCPgY8_VJIQu_9T9PTgS-xIHpWuT0CYLfI,29591
28
- bitvavo_client/facade.py,sha256=LZELLda4x--6AUAgrloTgWIWTmt_iVMMcSkEp8xf2P4,2644
27
+ bitvavo_client/endpoints/private.py,sha256=yChMe-HL2wFm6GBYoghURjdLzSxCPTYlEIOjysCrn3E,34589
28
+ bitvavo_client/endpoints/public.py,sha256=EY7y3vuuo3li1BiPCM2KfSNnoa91X426DPtp8BvHiS8,21944
29
+ bitvavo_client/facade.py,sha256=y08585HMAhYGRI51dUbBezsTFOoZFKgpMZ4GA39r1ew,4368
29
30
  bitvavo_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
31
  bitvavo_client/schemas/__init__.py,sha256=udqMyAFElrcBbNJIhoarQuTI-CF425JvPFqgLVjILU8,1126
31
32
  bitvavo_client/schemas/private_schemas.py,sha256=cG-cV5HKO8ZvWp3hjUPBFMT6Th0UBumMViI8gAZTyik,6143
32
33
  bitvavo_client/schemas/public_schemas.py,sha256=zfV6C_PQvNLLYEWS72ZD77Nm3XtRrEghKRhaFpgWHnI,4746
33
34
  bitvavo_client/transport/__init__.py,sha256=H7txnyuz6v84_GzdBiqpsehVQitEymgUTA5AJPeUEvg,44
34
- bitvavo_client/transport/http.py,sha256=fJX-OfLIox-UOvwuUztPcu95whXAmkfFdo8cQoYdfK0,5614
35
+ bitvavo_client/transport/http.py,sha256=H5N-ZQbKwtKsGNadi6YB1gTCViZSns2TMLCGY0LiKgQ,6144
35
36
  bitvavo_client/ws/__init__.py,sha256=Q4SVEq3EihXLVUKpguMdxrhfNAoU8cncpMFbU6kIX_0,44
36
- bitvavo_api_upgraded-4.1.2.dist-info/WHEEL,sha256=Jb20R3Ili4n9P1fcwuLup21eQ5r9WXhs4_qy7VTrgPI,79
37
- bitvavo_api_upgraded-4.1.2.dist-info/METADATA,sha256=ToW1wOCtabb5dLJDkQSHPDbVFuAM36jb3f14RQb00a4,35937
38
- bitvavo_api_upgraded-4.1.2.dist-info/RECORD,,
37
+ bitvavo_api_upgraded-4.2.1.dist-info/WHEEL,sha256=Jb20R3Ili4n9P1fcwuLup21eQ5r9WXhs4_qy7VTrgPI,79
38
+ bitvavo_api_upgraded-4.2.1.dist-info/METADATA,sha256=qil3_u24JDZf1uKRE22_kX87viD56lXcSZw4R6o8EdU,35937
39
+ bitvavo_api_upgraded-4.2.1.dist-info/RECORD,,
@@ -3,6 +3,20 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import time
6
+ from typing import Protocol
7
+
8
+
9
+ class RateLimitStrategy(Protocol):
10
+ """Protocol for custom rate limit handling strategies."""
11
+
12
+ def __call__(self, manager: RateLimitManager, idx: int, weight: int) -> None: ...
13
+
14
+
15
+ class DefaultRateLimitStrategy(RateLimitStrategy):
16
+ """Default RateLimitStrategy implementation that sleeps until the key's rate limit resets."""
17
+
18
+ def __call__(self, manager: RateLimitManager, idx: int, _: int) -> None:
19
+ manager.sleep_until_reset(idx)
6
20
 
7
21
 
8
22
  class RateLimitManager:
@@ -12,16 +26,20 @@ class RateLimitManager:
12
26
  for keyless requests.
13
27
  """
14
28
 
15
- def __init__(self, default_remaining: int, buffer: int) -> None:
29
+ def __init__(self, default_remaining: int, buffer: int, strategy: RateLimitStrategy | None = None) -> None:
16
30
  """Initialize rate limit manager.
17
31
 
18
32
  Args:
19
33
  default_remaining: Default rate limit amount
20
34
  buffer: Buffer to keep before hitting limit
35
+ strategy: Optional strategy callback when rate limit exceeded
21
36
  """
37
+ self.default_remaining: int = default_remaining
22
38
  self.state: dict[int, dict[str, int]] = {-1: {"remaining": default_remaining, "resetAt": 0}}
23
39
  self.buffer: int = buffer
24
40
 
41
+ self._strategy: RateLimitStrategy = strategy or DefaultRateLimitStrategy()
42
+
25
43
  def ensure_key(self, idx: int) -> None:
26
44
  """Ensure a key index exists in the state."""
27
45
  if idx not in self.state:
@@ -79,6 +97,16 @@ class RateLimitManager:
79
97
  ms_left = max(0, self.state[idx]["resetAt"] - now)
80
98
  time.sleep(ms_left / 1000 + 1)
81
99
 
100
+ def handle_limit(self, idx: int, weight: int) -> None:
101
+ """Invoke the configured strategy when rate limit is exceeded."""
102
+ self._strategy(self, idx, weight)
103
+
104
+ def reset_key(self, idx: int) -> None:
105
+ """Reset the remaining budget and reset time for a key index."""
106
+ self.ensure_key(idx)
107
+ self.state[idx]["remaining"] = self.default_remaining
108
+ self.state[idx]["resetAt"] = 0
109
+
82
110
  def get_remaining(self, idx: int) -> int:
83
111
  """Get remaining rate limit for key index.
84
112
 
@@ -0,0 +1,305 @@
1
+ """Shared endpoint utilities for model resolution and DataFrame handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from typing import TYPE_CHECKING, Any, TypeVar
7
+
8
+ from returns.result import Failure, Result, Success
9
+
10
+ from bitvavo_client.adapters.returns_adapter import BitvavoError
11
+ from bitvavo_client.core.model_preferences import ModelPreference
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ # DataFrames preference to library mapping
20
+ _DATAFRAME_LIBRARY_MAP = {
21
+ ModelPreference.POLARS: ("polars", "pl.DataFrame"),
22
+ ModelPreference.PANDAS: ("pandas", "pd.DataFrame"),
23
+ ModelPreference.PYARROW: ("pyarrow", "pa.Table"),
24
+ ModelPreference.DASK: ("dask", "dd.DataFrame"),
25
+ ModelPreference.MODIN: ("modin", "mpd.DataFrame"),
26
+ ModelPreference.CUDF: ("cudf", "cudf.DataFrame"),
27
+ ModelPreference.IBIS: ("ibis", "ibis.Table"),
28
+ }
29
+
30
+
31
+ def _extract_dataframe_data(data: Any, *, items_key: str | None = None) -> list[dict] | dict:
32
+ """Extract the meaningful data for DataFrame creation from API responses."""
33
+ if items_key and isinstance(data, dict) and items_key in data:
34
+ items = data[items_key]
35
+ if not isinstance(items, list):
36
+ msg = f"Expected {items_key} to be a list, got {type(items)}"
37
+ raise ValueError(msg)
38
+ return items
39
+ if isinstance(data, list):
40
+ return data
41
+ if isinstance(data, dict):
42
+ return [data]
43
+ msg = f"Unexpected data type for DataFrame creation: {type(data)}"
44
+ raise ValueError(msg)
45
+
46
+
47
+ def _get_dataframe_constructor(preference: ModelPreference) -> tuple[Any, str]: # noqa: PLR0911 (Too many return statements)
48
+ """Get the appropriate dataframe constructor and library name based on preference."""
49
+ if preference not in _DATAFRAME_LIBRARY_MAP:
50
+ msg = f"Unsupported dataframe preference: {preference}"
51
+ raise ValueError(msg)
52
+
53
+ library_name, _ = _DATAFRAME_LIBRARY_MAP[preference]
54
+
55
+ try:
56
+ if preference == ModelPreference.POLARS:
57
+ import polars as pl # noqa: PLC0415
58
+
59
+ return pl.DataFrame, library_name
60
+ if preference == ModelPreference.PANDAS:
61
+ import pandas as pd # noqa: PLC0415
62
+
63
+ return pd.DataFrame, library_name
64
+ if preference == ModelPreference.PYARROW:
65
+ import pyarrow as pa # noqa: PLC0415
66
+
67
+ return pa.Table.from_pylist, library_name
68
+ if preference == ModelPreference.DASK:
69
+ import dask.dataframe as dd # noqa: PLC0415
70
+ import pandas as pd # noqa: PLC0415
71
+
72
+ return lambda data, **_: dd.from_pandas(pd.DataFrame(data), npartitions=1), library_name
73
+ if preference == ModelPreference.MODIN:
74
+ import modin.pandas as mpd # noqa: PLC0415
75
+
76
+ return mpd.DataFrame, library_name
77
+ if preference == ModelPreference.CUDF:
78
+ import cudf # noqa: PLC0415
79
+
80
+ return cudf.DataFrame, library_name
81
+ import ibis # noqa: PLC0415
82
+
83
+ return lambda data, **_: ibis.memtable(data), library_name
84
+ except ImportError as e: # pragma: no cover - import failure is environment dependent
85
+ msg = f"{library_name} is not installed. Install with appropriate package manager."
86
+ raise ImportError(msg) from e
87
+
88
+
89
+ def _create_dataframe_with_constructor( # noqa: C901 (is too complex)
90
+ constructor: Any, library_name: str, df_data: list | dict, empty_schema: dict | None
91
+ ) -> Any:
92
+ """Create DataFrame with data using the appropriate constructor."""
93
+
94
+ # Helper function to check if data is array-like and schema exists
95
+ def _is_array_data_with_schema() -> bool:
96
+ return bool(empty_schema and isinstance(df_data, list) and df_data and isinstance(df_data[0], (list, tuple)))
97
+
98
+ if library_name == "polars":
99
+ # Handle special case for array data (like candles) with schema
100
+ if _is_array_data_with_schema() and empty_schema:
101
+ # Transform array data into named columns based on schema
102
+ column_names = list(empty_schema.keys())
103
+ if len(df_data[0]) == len(column_names): # type: ignore[index]
104
+ # Create DataFrame with explicit column names
105
+ import polars as pl # noqa: PLC0415
106
+
107
+ df = pl.DataFrame(df_data, schema=column_names, orient="row")
108
+ df = _apply_polars_schema(df, empty_schema)
109
+ return df
110
+
111
+ df = constructor(df_data, strict=False)
112
+ if empty_schema:
113
+ df = _apply_polars_schema(df, empty_schema)
114
+ return df
115
+
116
+ if library_name in ("pandas", "modin", "cudf"):
117
+ # Handle special case for array data (like candles) with schema
118
+ if _is_array_data_with_schema() and empty_schema:
119
+ # Transform array data into named columns based on schema
120
+ column_names = list(empty_schema.keys())
121
+ if len(df_data[0]) == len(column_names): # type: ignore[index]
122
+ # Create DataFrame with explicit column names
123
+ df = constructor(df_data, columns=column_names)
124
+ if empty_schema:
125
+ df = _apply_pandas_like_schema(df, empty_schema)
126
+ return df
127
+
128
+ df = constructor(df_data)
129
+ if empty_schema:
130
+ df = _apply_pandas_like_schema(df, empty_schema)
131
+ return df
132
+
133
+ if library_name in ("pyarrow", "dask"):
134
+ return constructor(df_data)
135
+
136
+ return constructor(df_data)
137
+
138
+
139
+ def _create_empty_dataframe(constructor: Any, library_name: str, empty_schema: dict | None) -> Any:
140
+ """Create empty DataFrame using the appropriate constructor."""
141
+ if empty_schema is None:
142
+ empty_schema = {"id": str} if library_name != "polars" else {"id": "pl.String"}
143
+
144
+ if library_name == "polars":
145
+ import polars as pl # noqa: PLC0415
146
+
147
+ schema = {k: pl.String if v == "pl.String" else v for k, v in empty_schema.items()}
148
+ return constructor([], schema=schema)
149
+ if library_name in ("pandas", "modin", "cudf", "dask") or library_name == "pyarrow":
150
+ return constructor([])
151
+ return constructor([dict.fromkeys(empty_schema.keys())])
152
+
153
+
154
+ def _apply_polars_schema(df: Any, schema: dict) -> Any:
155
+ """Apply schema to polars DataFrame."""
156
+ import polars as pl # noqa: PLC0415
157
+
158
+ for col, expected_dtype in schema.items():
159
+ if col in df.columns:
160
+ with contextlib.suppress(Exception):
161
+ df = df.with_columns(pl.col(col).cast(expected_dtype))
162
+ return df
163
+
164
+
165
+ def _apply_pandas_like_schema(df: Any, schema: dict) -> Any:
166
+ """Apply schema to pandas-like DataFrame."""
167
+ pandas_schema = {}
168
+ for col, dtype in schema.items():
169
+ if col in df.columns:
170
+ if "String" in str(dtype) or "Utf8" in str(dtype):
171
+ pandas_schema[col] = "string"
172
+ elif "Int" in str(dtype):
173
+ pandas_schema[col] = "int64"
174
+ elif "Float" in str(dtype):
175
+ pandas_schema[col] = "float64"
176
+
177
+ if pandas_schema and hasattr(df, "astype"):
178
+ with contextlib.suppress(Exception):
179
+ df = df.astype(pandas_schema)
180
+ return df
181
+
182
+
183
+ def _create_dataframe_from_data(
184
+ data: Any, preference: ModelPreference, *, items_key: str | None = None, empty_schema: dict[str, Any] | None = None
185
+ ) -> Result[Any, BitvavoError]:
186
+ """Create a DataFrame from API response data using the specified preference."""
187
+ try:
188
+ df_data = _extract_dataframe_data(data, items_key=items_key)
189
+ constructor, library_name = _get_dataframe_constructor(preference)
190
+
191
+ if df_data:
192
+ df = _create_dataframe_with_constructor(constructor, library_name, df_data, empty_schema)
193
+ return Success(df) # type: ignore[return-value]
194
+
195
+ df = _create_empty_dataframe(constructor, library_name, empty_schema)
196
+ return Success(df) # type: ignore[return-value]
197
+
198
+ except (ValueError, TypeError, ImportError) as exc:
199
+ error = BitvavoError(
200
+ http_status=500,
201
+ error_code=-1,
202
+ reason="DataFrame creation failed",
203
+ message=f"Failed to create DataFrame from API response: {exc}",
204
+ raw={"data_type": type(data).__name__, "data_sample": str(data)[:200]},
205
+ )
206
+ return Failure(error)
207
+
208
+
209
+ class BaseAPI:
210
+ """Base class for API endpoint handlers providing model conversion utilities."""
211
+
212
+ _endpoint_models: Mapping[str, Any] = {}
213
+ _default_schemas: Mapping[str, object] = {}
214
+
215
+ def __init__(
216
+ self,
217
+ http_client: Any,
218
+ *,
219
+ preferred_model: ModelPreference | str | None = None,
220
+ default_schema: Mapping[str, object] | None = None,
221
+ ) -> None:
222
+ self.http = http_client
223
+
224
+ if preferred_model is None:
225
+ self.preferred_model = None
226
+ elif isinstance(preferred_model, ModelPreference):
227
+ self.preferred_model = preferred_model
228
+ elif isinstance(preferred_model, str):
229
+ try:
230
+ self.preferred_model = ModelPreference(preferred_model)
231
+ except ValueError:
232
+ self.preferred_model = preferred_model
233
+ else:
234
+ self.preferred_model = preferred_model
235
+
236
+ self.default_schema = default_schema
237
+
238
+ def _get_effective_model(
239
+ self,
240
+ endpoint_type: str,
241
+ model: type[T] | Any | None,
242
+ schema: Mapping[str, object] | None,
243
+ ) -> tuple[type[T] | Any | None, Mapping[str, object] | None]:
244
+ if model is not None:
245
+ return model, schema
246
+
247
+ if self.preferred_model is None or self.preferred_model == ModelPreference.RAW:
248
+ return Any, schema
249
+
250
+ if isinstance(self.preferred_model, ModelPreference) and self.preferred_model in _DATAFRAME_LIBRARY_MAP:
251
+ effective_schema = schema or self.default_schema or self._default_schemas.get(endpoint_type)
252
+ if effective_schema is not None and not isinstance(effective_schema, dict):
253
+ effective_schema = dict(effective_schema)
254
+ return self.preferred_model, effective_schema
255
+
256
+ if self.preferred_model == ModelPreference.PYDANTIC:
257
+ model_cls = self._endpoint_models.get(endpoint_type, dict)
258
+ return model_cls, schema
259
+
260
+ return None, schema
261
+
262
+ def _convert_raw_result(
263
+ self,
264
+ raw_result: Result[Any, BitvavoError | Any],
265
+ endpoint_type: str,
266
+ model: type[T] | Any | None,
267
+ schema: Mapping[str, object] | None,
268
+ *,
269
+ items_key: str | None = None,
270
+ ) -> Result[Any, BitvavoError | Any]:
271
+ if isinstance(raw_result, Failure):
272
+ return raw_result
273
+
274
+ effective_model, effective_schema = self._get_effective_model(endpoint_type, model, schema)
275
+
276
+ if effective_model is Any or effective_model is None:
277
+ return raw_result
278
+
279
+ raw_data = raw_result.unwrap()
280
+
281
+ if isinstance(effective_model, ModelPreference) and effective_model in _DATAFRAME_LIBRARY_MAP:
282
+ return _create_dataframe_from_data(
283
+ raw_data,
284
+ effective_model,
285
+ items_key=items_key,
286
+ empty_schema=effective_schema, # type: ignore[arg-type]
287
+ )
288
+
289
+ try:
290
+ if hasattr(effective_model, "model_validate"):
291
+ parsed = effective_model.model_validate(raw_data) # type: ignore[misc]
292
+ elif effective_schema is None:
293
+ parsed = effective_model(raw_data) # type: ignore[misc]
294
+ else:
295
+ parsed = effective_model(raw_data, schema=effective_schema) # type: ignore[misc]
296
+ return Success(parsed)
297
+ except (ValueError, TypeError, AttributeError) as exc:
298
+ error = BitvavoError(
299
+ http_status=500,
300
+ error_code=-1,
301
+ reason="Model conversion failed",
302
+ message=str(exc),
303
+ raw=raw_data if isinstance(raw_data, dict) else {"raw": raw_data},
304
+ )
305
+ return Failure(error)
@@ -2,8 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import contextlib
6
- from typing import TYPE_CHECKING, Any, TypeVar
5
+ from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
7
6
 
8
7
  import httpx
9
8
  from returns.result import Failure, Result, Success
@@ -11,6 +10,11 @@ from returns.result import Failure, Result, Success
11
10
  from bitvavo_client.adapters.returns_adapter import BitvavoError
12
11
  from bitvavo_client.core import private_models
13
12
  from bitvavo_client.core.model_preferences import ModelPreference
13
+ from bitvavo_client.endpoints.base import (
14
+ _DATAFRAME_LIBRARY_MAP,
15
+ BaseAPI,
16
+ _create_dataframe_from_data,
17
+ )
14
18
  from bitvavo_client.endpoints.common import create_postfix, default
15
19
  from bitvavo_client.schemas.private_schemas import DEFAULT_SCHEMAS
16
20
 
@@ -21,209 +25,26 @@ if TYPE_CHECKING: # pragma: no cover
21
25
  T = TypeVar("T")
22
26
 
23
27
 
24
- def _extract_dataframe_data(data: Any, *, items_key: str | None = None) -> list[dict] | dict:
25
- """Extract the meaningful data for DataFrame creation from API responses.
26
-
27
- Args:
28
- data: Raw API response data
29
- items_key: Key to extract from nested response (e.g., 'items' for paginated responses)
30
-
31
- Returns:
32
- List of dicts or single dict suitable for DataFrame creation
33
-
34
- Raises:
35
- ValueError: If data format is unexpected
36
- """
37
- if items_key and isinstance(data, dict) and items_key in data:
38
- # Extract nested items (e.g., transaction_history['items'])
39
- items = data[items_key]
40
- if not isinstance(items, list):
41
- msg = f"Expected {items_key} to be a list, got {type(items)}"
42
- raise ValueError(msg)
43
- return items
44
- if isinstance(data, list):
45
- # Direct list response (e.g., balance, trades)
46
- return data
47
- if isinstance(data, dict):
48
- # Single dict response - wrap in list for DataFrame
49
- return [data]
50
-
51
- msg = f"Unexpected data type for DataFrame creation: {type(data)}"
52
- raise ValueError(msg)
53
-
54
-
55
- # DataFrames preference to library mapping
56
- _DATAFRAME_LIBRARY_MAP = {
57
- ModelPreference.POLARS: ("polars", "pl.DataFrame"),
58
- ModelPreference.PANDAS: ("pandas", "pd.DataFrame"),
59
- ModelPreference.PYARROW: ("pyarrow", "pa.Table"),
60
- ModelPreference.DASK: ("dask", "dd.DataFrame"),
61
- ModelPreference.MODIN: ("modin", "mpd.DataFrame"),
62
- ModelPreference.CUDF: ("cudf", "cudf.DataFrame"),
63
- ModelPreference.IBIS: ("ibis", "ibis.Table"),
64
- }
65
-
66
-
67
- def _get_dataframe_constructor(preference: ModelPreference) -> tuple[Any, str]:
68
- """Get the appropriate dataframe constructor and library name based on preference.
69
-
70
- Args:
71
- preference: ModelPreference enum value
72
-
73
- Returns:
74
- Tuple of (constructor_class, library_name)
75
-
76
- Raises:
77
- ImportError: If required library is not available
78
- ValueError: If preference is not a supported dataframe type
79
- """
80
- if preference not in _DATAFRAME_LIBRARY_MAP:
81
- msg = f"Unsupported dataframe preference: {preference}"
82
- raise ValueError(msg)
83
-
84
- library_name, _ = _DATAFRAME_LIBRARY_MAP[preference]
85
-
86
- try:
87
- if preference == ModelPreference.POLARS:
88
- import polars as pl # noqa: PLC0415
89
-
90
- return pl.DataFrame, library_name
91
- if preference == ModelPreference.PANDAS:
92
- import pandas as pd # noqa: PLC0415
93
-
94
- return pd.DataFrame, library_name
95
- if preference == ModelPreference.PYARROW:
96
- import pyarrow as pa # noqa: PLC0415
97
-
98
- return pa.Table.from_pylist, library_name
99
- if preference == ModelPreference.DASK:
100
- import dask.dataframe as dd # noqa: PLC0415
101
- import pandas as pd # noqa: PLC0415
102
-
103
- return lambda data, **kwargs: dd.from_pandas(pd.DataFrame(data), npartitions=1), library_name
104
- if preference == ModelPreference.MODIN:
105
- import modin.pandas as mpd # noqa: PLC0415
106
-
107
- return mpd.DataFrame, library_name
108
- if preference == ModelPreference.CUDF:
109
- import cudf # noqa: PLC0415
110
-
111
- return cudf.DataFrame, library_name
112
- # ModelPreference.IBIS
113
- import ibis # noqa: PLC0415
114
-
115
- return lambda data, **kwargs: ibis.memtable(data), library_name
116
- except ImportError as e:
117
- msg = f"{library_name} is not installed. Install with appropriate package manager."
118
- raise ImportError(msg) from e
119
-
120
-
121
- def _create_dataframe_from_data(
122
- data: Any, preference: ModelPreference, *, items_key: str | None = None, empty_schema: dict[str, Any] | None = None
123
- ) -> Result[Any, BitvavoError]:
124
- """Create a DataFrame from API response data using the specified preference.
125
-
126
- Args:
127
- data: Raw API response data
128
- preference: ModelPreference enum value indicating which library to use
129
- items_key: Key to extract from nested response (optional)
130
- empty_schema: Schema to use for empty DataFrames and type casting
131
-
132
- Returns:
133
- Result containing DataFrame or error
134
- """
135
- try:
136
- df_data = _extract_dataframe_data(data, items_key=items_key)
137
- constructor, library_name = _get_dataframe_constructor(preference)
138
-
139
- if df_data:
140
- # Create DataFrame using appropriate constructor
141
- df = _create_dataframe_with_constructor(constructor, library_name, df_data, empty_schema)
142
- return Success(df) # type: ignore[return-value]
143
-
144
- # Create empty DataFrame
145
- df = _create_empty_dataframe(constructor, library_name, empty_schema)
146
- return Success(df) # type: ignore[return-value]
147
-
148
- except (ValueError, TypeError, ImportError) as exc:
149
- error = BitvavoError(
150
- http_status=500,
151
- error_code=-1,
152
- reason="DataFrame creation failed",
153
- message=f"Failed to create DataFrame from API response: {exc}",
154
- raw={"data_type": type(data).__name__, "data_sample": str(data)[:200]},
155
- )
156
- return Failure(error)
157
-
158
-
159
- def _create_dataframe_with_constructor(
160
- constructor: Any, library_name: str, df_data: list | dict, empty_schema: dict | None
161
- ) -> Any:
162
- """Create DataFrame with data using the appropriate constructor."""
163
- if library_name == "polars":
164
- df = constructor(df_data, strict=False)
165
- if empty_schema:
166
- df = _apply_polars_schema(df, empty_schema)
167
- return df
168
- if library_name in ("pandas", "modin", "cudf"):
169
- df = constructor(df_data)
170
- if empty_schema:
171
- df = _apply_pandas_like_schema(df, empty_schema)
172
- return df
173
- if library_name == "pyarrow" or library_name == "dask":
174
- return constructor(df_data)
175
- # ibis
176
- return constructor(df_data)
177
-
178
-
179
- def _create_empty_dataframe(constructor: Any, library_name: str, empty_schema: dict | None) -> Any:
180
- """Create empty DataFrame using the appropriate constructor."""
181
- if empty_schema is None:
182
- empty_schema = {"id": str} if library_name != "polars" else {"id": "pl.String"}
183
-
184
- if library_name == "polars":
185
- import polars as pl # noqa: PLC0415
186
-
187
- schema = {k: pl.String if v == "pl.String" else v for k, v in empty_schema.items()}
188
- return constructor([], schema=schema)
189
- if library_name in ("pandas", "modin", "cudf", "dask") or library_name == "pyarrow":
190
- return constructor([])
191
- # ibis
192
- return constructor([dict.fromkeys(empty_schema.keys())])
193
-
194
-
195
- def _apply_polars_schema(df: Any, schema: dict) -> Any:
196
- """Apply schema to polars DataFrame."""
197
- import polars as pl # noqa: PLC0415
198
-
199
- for col, expected_dtype in schema.items():
200
- if col in df.columns:
201
- with contextlib.suppress(Exception):
202
- df = df.with_columns(pl.col(col).cast(expected_dtype))
203
- return df
204
-
205
-
206
- def _apply_pandas_like_schema(df: Any, schema: dict) -> Any:
207
- """Apply schema to pandas-like DataFrame."""
208
- pandas_schema = {}
209
- for col, dtype in schema.items():
210
- if col in df.columns:
211
- if "String" in str(dtype) or "Utf8" in str(dtype):
212
- pandas_schema[col] = "string"
213
- elif "Int" in str(dtype):
214
- pandas_schema[col] = "int64"
215
- elif "Float" in str(dtype):
216
- pandas_schema[col] = "float64"
217
-
218
- if pandas_schema and hasattr(df, "astype"):
219
- with contextlib.suppress(Exception):
220
- df = df.astype(pandas_schema)
221
- return df
222
-
223
-
224
- class PrivateAPI:
28
+ class PrivateAPI(BaseAPI):
225
29
  """Handles all private Bitvavo API endpoints requiring authentication."""
226
30
 
31
+ _endpoint_models: ClassVar[dict[str, Any]] = {
32
+ "account": private_models.Account,
33
+ "balance": private_models.Balances,
34
+ "orders": private_models.Orders,
35
+ "order": private_models.Order,
36
+ "trade_history": private_models.Trades,
37
+ "transaction_history": private_models.TransactionHistory,
38
+ "fees": private_models.Fees,
39
+ "deposit_history": private_models.DepositHistories,
40
+ "deposit": private_models.Deposit,
41
+ "withdrawals": private_models.Withdrawals,
42
+ "withdraw": private_models.WithdrawResponse,
43
+ "cancel_order": private_models.CancelOrderResponse,
44
+ }
45
+
46
+ _default_schemas = DEFAULT_SCHEMAS
47
+
227
48
  def __init__(
228
49
  self,
229
50
  http_client: HTTPClient,
@@ -231,166 +52,8 @@ class PrivateAPI:
231
52
  preferred_model: ModelPreference | str | None = None,
232
53
  default_schema: dict | None = None,
233
54
  ) -> None:
234
- """Initialize private API handler.
235
-
236
- Args:
237
- http_client: HTTP client for making requests
238
- preferred_model: Preferred model format for responses
239
- default_schema: Default schema for DataFrame conversion
240
- """
241
- self.http: HTTPClient = http_client
242
-
243
- # Handle preferred_model parameter - try to convert strings to ModelPreference,
244
- # but allow arbitrary strings to pass through for custom handling
245
- if preferred_model is None:
246
- self.preferred_model = None
247
- elif isinstance(preferred_model, ModelPreference):
248
- self.preferred_model = preferred_model
249
- elif isinstance(preferred_model, str):
250
- try:
251
- self.preferred_model = ModelPreference(preferred_model)
252
- except ValueError:
253
- # If string doesn't match a valid ModelPreference, store as-is
254
- self.preferred_model = preferred_model
255
- else:
256
- self.preferred_model = preferred_model
257
-
258
- self.default_schema = default_schema
259
-
260
- def _get_effective_model(
261
- self,
262
- endpoint_type: str,
263
- model: type[T] | Any | None,
264
- schema: dict | None,
265
- ) -> tuple[type[T] | Any | None, dict | None]:
266
- """Get the effective model and schema to use for a request.
267
-
268
- Args:
269
- endpoint_type: Type of endpoint (e.g., 'account', 'balance', 'orders')
270
- model: Model explicitly passed to method (overrides preference)
271
- schema: Schema explicitly passed to method
272
-
273
- Returns:
274
- Tuple of (effective_model, effective_schema)
275
-
276
- Raises:
277
- ValueError: If endpoint_type is not recognized
278
- """
279
- # If model is explicitly provided, use it
280
- if model is not None:
281
- return model, schema
282
-
283
- # If no preferred model is set, return None (raw response)
284
- if self.preferred_model is None:
285
- return None, schema
286
-
287
- # Apply preference based on enum value
288
- if self.preferred_model == ModelPreference.RAW:
289
- return Any, schema
290
-
291
- # Handle all DataFrame preferences
292
- if self.preferred_model in _DATAFRAME_LIBRARY_MAP:
293
- # Validate endpoint type
294
- if endpoint_type not in list(DEFAULT_SCHEMAS.keys()):
295
- msg = f"Invalid endpoint_type '{endpoint_type}'. Valid types: {sorted(DEFAULT_SCHEMAS)}"
296
- raise ValueError(msg)
297
- # Use the provided schema, fallback to instance default, then to endpoint-specific default
298
- effective_schema = schema or self.default_schema or DEFAULT_SCHEMAS.get(endpoint_type)
299
- # Convert to dict if it's a Mapping but not already a dict
300
- if effective_schema is not None and not isinstance(effective_schema, dict):
301
- effective_schema = dict(effective_schema)
302
- # Return the preference itself, not a specific DataFrame class
303
- return self.preferred_model, effective_schema
304
-
305
- if self.preferred_model == ModelPreference.PYDANTIC:
306
- # Map endpoint types to appropriate Pydantic models
307
- endpoint_model_map = {
308
- "account": private_models.Account,
309
- "balance": private_models.Balances,
310
- "orders": private_models.Orders,
311
- "order": private_models.Order,
312
- "trade_history": private_models.Trades,
313
- "transaction_history": private_models.TransactionHistory,
314
- "fees": private_models.Fees,
315
- "deposit_history": private_models.DepositHistories,
316
- "deposit": private_models.Deposit,
317
- "withdrawals": private_models.Withdrawals,
318
- "withdraw": private_models.WithdrawResponse,
319
- "cancel_order": private_models.CancelOrderResponse,
320
- }
321
- if endpoint_type not in endpoint_model_map:
322
- msg = f"No Pydantic model defined for endpoint_type '{endpoint_type}'. Add it to endpoint_model_map."
323
- raise ValueError(msg)
324
- return endpoint_model_map[endpoint_type], schema
325
-
326
- # Default case (AUTO or unknown)
327
- return None, schema
328
-
329
- def _convert_raw_result(
330
- self,
331
- raw_result: Result[Any, BitvavoError | httpx.HTTPError],
332
- endpoint_type: str,
333
- model: type[T] | Any | None,
334
- schema: dict | None,
335
- *,
336
- items_key: str | None = None,
337
- ) -> Result[Any, BitvavoError | httpx.HTTPError]:
338
- """Convert raw API result to the desired model format.
339
-
340
- Args:
341
- raw_result: Raw result from HTTP client
342
- endpoint_type: Type of endpoint (e.g., 'account', 'balance', 'orders')
343
- model: Model explicitly passed to method (overrides preference)
344
- schema: Schema explicitly passed to method
345
- items_key: Key to extract from nested response for DataFrames
346
-
347
- Returns:
348
- Result with converted data or original error
349
- """
350
- # If the raw result is an error, return it as-is
351
- if isinstance(raw_result, Failure):
352
- return raw_result
353
-
354
- # Get the effective model and schema to use
355
- effective_model, effective_schema = self._get_effective_model(endpoint_type, model, schema)
356
-
357
- # If no conversion needed (raw data requested), return as-is
358
- if effective_model is Any or effective_model is None:
359
- return raw_result
360
-
361
- # Extract the raw data
362
- raw_data = raw_result.unwrap()
363
-
364
- # Handle DataFrames specially
365
- if effective_model in _DATAFRAME_LIBRARY_MAP and isinstance(effective_model, ModelPreference):
366
- return _create_dataframe_from_data(
367
- raw_data, effective_model, items_key=items_key, empty_schema=effective_schema
368
- )
369
-
370
- # Handle other model types using the same logic as PublicAPI
371
- try:
372
- # Handle different model types
373
- if hasattr(effective_model, "model_validate"):
374
- # Pydantic model
375
- parsed = effective_model.model_validate(raw_data) # type: ignore[misc]
376
- elif effective_schema is None:
377
- # Simple constructor call - this handles dict and other simple types
378
- parsed = effective_model(raw_data) # type: ignore[misc]
379
- else:
380
- # Other models with schema
381
- parsed = effective_model(raw_data, schema=effective_schema) # type: ignore[misc]
382
-
383
- return Success(parsed)
384
- except (ValueError, TypeError, AttributeError) as exc:
385
- # If conversion fails, return a structured error
386
- error = BitvavoError(
387
- http_status=500,
388
- error_code=-1,
389
- reason="Model conversion failed",
390
- message=str(exc),
391
- raw=raw_data if isinstance(raw_data, dict) else {"raw": raw_data},
392
- )
393
- return Failure(error)
55
+ """Initialize private API handler."""
56
+ super().__init__(http_client, preferred_model=preferred_model, default_schema=default_schema)
394
57
 
395
58
  def account(
396
59
  self,
@@ -1225,8 +888,8 @@ class PrivateAPI:
1225
888
  Returns:
1226
889
  Result containing withdrawal result or error
1227
890
  """
1228
- effective_model, effective_schema = self._get_effective_model("withdraw", model, schema)
1229
891
  body = {"symbol": symbol, "amount": amount, "address": address}
1230
892
  if options:
1231
893
  body.update(options)
1232
- return self.http.request("POST", "/withdrawal", body=body, weight=1)
894
+ raw_result = self.http.request("POST", "/withdrawal", body=body, weight=1)
895
+ return self._convert_raw_result(raw_result, "withdraw", model, schema)
@@ -2,13 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any, Literal, TypeVar
5
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
6
6
 
7
- from returns.result import Failure, Success
8
-
9
- from bitvavo_client.adapters.returns_adapter import BitvavoError
10
7
  from bitvavo_client.core import public_models
11
- from bitvavo_client.core.model_preferences import ModelPreference
8
+ from bitvavo_client.endpoints.base import BaseAPI
12
9
  from bitvavo_client.endpoints.common import create_postfix
13
10
  from bitvavo_client.schemas.public_schemas import DEFAULT_SCHEMAS
14
11
 
@@ -24,32 +21,39 @@ MAX_TIMESTAMP_VALUE = 8640000000000000 # Maximum allowed timestamp value
24
21
  MAX_BOOK_DEPTH = 1000 # Maximum depth for order book
25
22
  MAX_BOOK_REPORT_DEPTH = 1000 # Maximum depth for order book report
26
23
 
27
- # DataFrames preference to library mapping
28
- _DATAFRAME_LIBRARY_MAP = {
29
- ModelPreference.POLARS: ("polars", "pl.DataFrame"),
30
- ModelPreference.PANDAS: ("pandas", "pd.DataFrame"),
31
- ModelPreference.PYARROW: ("pyarrow", "pa.Table"),
32
- ModelPreference.DASK: ("dask", "dd.DataFrame"),
33
- ModelPreference.MODIN: ("modin", "mpd.DataFrame"),
34
- ModelPreference.CUDF: ("cudf", "cudf.DataFrame"),
35
- ModelPreference.IBIS: ("ibis", "ibis.Table"),
36
- }
37
-
38
24
  if TYPE_CHECKING: # pragma: no cover
39
25
  from collections.abc import Mapping
40
26
 
41
27
  import httpx
42
28
  from returns.result import Result
43
29
 
30
+ from bitvavo_client.adapters.returns_adapter import BitvavoError
31
+ from bitvavo_client.core.model_preferences import ModelPreference
44
32
  from bitvavo_client.core.types import AnyDict
45
33
  from bitvavo_client.transport.http import HTTPClient
46
34
 
47
35
  T = TypeVar("T")
48
36
 
49
37
 
50
- class PublicAPI:
38
+ class PublicAPI(BaseAPI):
51
39
  """Handles all public Bitvavo API endpoints."""
52
40
 
41
+ _endpoint_models: ClassVar[dict[str, type[Any]]] = {
42
+ "time": public_models.ServerTime,
43
+ "markets": public_models.Markets,
44
+ "assets": public_models.Assets,
45
+ "book": public_models.OrderBook,
46
+ "trades": public_models.Trades,
47
+ "candles": public_models.Candles,
48
+ "ticker_price": public_models.TickerPrices,
49
+ "ticker_book": public_models.TickerBooks,
50
+ "ticker_24h": public_models.Ticker24hs,
51
+ "report_book": public_models.OrderBookReport,
52
+ "report_trades": public_models.TradesReport,
53
+ }
54
+
55
+ _default_schemas = DEFAULT_SCHEMAS
56
+
53
57
  def __init__(
54
58
  self,
55
59
  http_client: HTTPClient,
@@ -57,209 +61,8 @@ class PublicAPI:
57
61
  preferred_model: ModelPreference | str | None = None,
58
62
  default_schema: Mapping[str, object] | None = None,
59
63
  ) -> None:
60
- """Initialize public API handler.
61
-
62
- Args:
63
- http_client: HTTP client for making requests
64
- preferred_model: Preferred model format for responses
65
- default_schema: Default schema for DataFrame conversion
66
- """
67
- self.http: HTTPClient = http_client
68
-
69
- # Handle preferred_model parameter - try to convert strings to ModelPreference,
70
- # but allow arbitrary strings to pass through for custom handling
71
- if preferred_model is None:
72
- self.preferred_model = None
73
- elif isinstance(preferred_model, ModelPreference):
74
- self.preferred_model = preferred_model
75
- elif isinstance(preferred_model, str):
76
- try:
77
- self.preferred_model = ModelPreference(preferred_model)
78
- except ValueError:
79
- # If string doesn't match a valid ModelPreference, store as-is
80
- self.preferred_model = preferred_model
81
- else:
82
- self.preferred_model = preferred_model
83
-
84
- # If using DATAFRAME preference without a default schema, we could provide sensible defaults
85
- # But keep it explicit for now - users can import and use schemas as needed
86
- self.default_schema = default_schema
87
-
88
- def _get_effective_model(
89
- self,
90
- endpoint_type: str,
91
- model: type[T] | Any | None,
92
- schema: Mapping[str, object] | None,
93
- ) -> tuple[type[T] | Any | None, Mapping[str, object] | None]:
94
- """Get the effective model and schema to use for a request.
95
-
96
- Args:
97
- endpoint_type: Type of endpoint (e.g., 'time', 'markets', 'assets')
98
- model: Model explicitly passed to method (overrides preference)
99
- schema: Schema explicitly passed to method
100
-
101
- Returns:
102
- Tuple of (effective_model, effective_schema)
103
- """
104
- # If model is explicitly provided, use it
105
- if model is not None:
106
- return model, schema
107
-
108
- # If no preferred model is set, return Any (raw response)
109
- if self.preferred_model is None:
110
- return Any, schema
111
-
112
- # Apply preference based on enum value
113
- if self.preferred_model == ModelPreference.RAW:
114
- return Any, schema
115
-
116
- # Handle all DataFrame preferences
117
- if self.preferred_model in _DATAFRAME_LIBRARY_MAP:
118
- # Use the provided schema, fallback to instance default, then to endpoint-specific default
119
- effective_schema = schema or self.default_schema or DEFAULT_SCHEMAS.get(endpoint_type)
120
- # Return the preference itself, not a specific DataFrame class
121
- return self.preferred_model, effective_schema
122
-
123
- if self.preferred_model == ModelPreference.PYDANTIC:
124
- # Map endpoint types to appropriate Pydantic models
125
- endpoint_model_map = {
126
- "time": public_models.ServerTime,
127
- "markets": public_models.Markets,
128
- "assets": public_models.Assets,
129
- "book": public_models.OrderBook,
130
- "trades": public_models.Trades,
131
- "candles": public_models.Candles,
132
- "ticker_price": public_models.TickerPrices,
133
- "ticker_book": public_models.TickerBooks,
134
- "ticker_24h": public_models.Ticker24hs,
135
- "report_book": public_models.OrderBookReport,
136
- "report_trades": public_models.TradesReport,
137
- }
138
- return endpoint_model_map.get(endpoint_type, dict), schema
139
-
140
- # Default case (AUTO or unknown)
141
- return None, schema
142
-
143
- def _convert_raw_result(
144
- self,
145
- raw_result: Result[Any, BitvavoError | httpx.HTTPError],
146
- endpoint_type: str,
147
- model: type[T] | Any | None,
148
- schema: Mapping[str, object] | None,
149
- ) -> Result[Any, BitvavoError | httpx.HTTPError]:
150
- """Convert raw API result to the desired model format.
151
-
152
- Args:
153
- raw_result: Raw result from HTTP client
154
- endpoint_type: Type of endpoint (e.g., 'time', 'markets', 'assets')
155
- model: Model explicitly passed to method (overrides preference)
156
- schema: Schema explicitly passed to method
157
-
158
- Returns:
159
- Result with converted data or original error
160
- """
161
- # If the raw result is an error, return it as-is
162
- if isinstance(raw_result, Failure):
163
- return raw_result
164
-
165
- # Get the effective model and schema to use
166
- effective_model, effective_schema = self._get_effective_model(endpoint_type, model, schema)
167
-
168
- # If no conversion needed (raw data requested), return as-is
169
- if effective_model is Any or effective_model is None or effective_model == ModelPreference.RAW:
170
- return raw_result
171
-
172
- # Extract the raw data
173
- raw_data = raw_result.unwrap()
174
-
175
- # Perform conversion
176
- try:
177
- # Handle DataFrame preferences specially
178
- if isinstance(effective_model, ModelPreference) and effective_model in _DATAFRAME_LIBRARY_MAP:
179
- parsed = self._create_dataframe(raw_data, effective_model, effective_schema)
180
- elif hasattr(effective_model, "model_validate"):
181
- # Pydantic model
182
- parsed = effective_model.model_validate(raw_data) # type: ignore[misc]
183
- else:
184
- # Simple constructor call - this handles dict and other simple types
185
- parsed = effective_model(raw_data) # type: ignore[misc]
186
-
187
- return Success(parsed)
188
- except (ValueError, TypeError, AttributeError) as exc:
189
- # If conversion fails, return a structured error
190
- error = BitvavoError(
191
- http_status=500,
192
- error_code=-1,
193
- reason="Model conversion failed",
194
- message=str(exc),
195
- raw=raw_data if isinstance(raw_data, dict) else {"raw": raw_data},
196
- )
197
- return Failure(error)
198
-
199
- def _create_dataframe(
200
- self,
201
- data: Any,
202
- preference: ModelPreference,
203
- schema: Mapping[str, object] | None,
204
- ) -> Any:
205
- """Create a DataFrame from raw data using the specified preference.
206
-
207
- Args:
208
- data: Raw data to convert
209
- preference: DataFrame preference (POLARS, PANDAS, etc.)
210
- schema: Schema for DataFrame conversion
211
-
212
- Returns:
213
- DataFrame instance
214
-
215
- Raises:
216
- ImportError: If the required DataFrame library is not available
217
- ValueError: If DataFrame creation fails
218
- """
219
- if preference == ModelPreference.POLARS:
220
- import polars as pl
221
-
222
- return self._create_polars_dataframe(data, schema, pl)
223
-
224
- if preference == ModelPreference.PANDAS:
225
- import pandas as pd
226
-
227
- return pd.DataFrame(data)
228
-
229
- if preference == ModelPreference.PYARROW:
230
- import pyarrow as pa
231
-
232
- return pa.Table.from_pylist(data if isinstance(data, list) else [data])
233
-
234
- # For other DataFrame types, try basic conversion
235
- msg = f"DataFrame preference {preference} not yet fully implemented"
236
- raise NotImplementedError(msg)
237
-
238
- def _create_polars_dataframe(
239
- self,
240
- data: Any,
241
- schema: Mapping[str, object] | None,
242
- pl: Any, # polars module
243
- ) -> Any:
244
- """Create a Polars DataFrame with proper schema handling.
245
-
246
- Args:
247
- data: Raw data to convert
248
- schema: Schema for DataFrame conversion
249
- pl: Polars module
250
-
251
- Returns:
252
- Polars DataFrame
253
- """
254
- if schema is None:
255
- return pl.DataFrame(data)
256
-
257
- # For Polars, we need to handle the schema with strict=False for compatibility
258
- try:
259
- return pl.DataFrame(data, schema=schema, strict=False)
260
- except Exception:
261
- # Fallback to basic DataFrame creation if schema fails
262
- return pl.DataFrame(data)
64
+ """Initialize public API handler."""
65
+ super().__init__(http_client, preferred_model=preferred_model, default_schema=default_schema)
263
66
 
264
67
  def time(
265
68
  self,
bitvavo_client/facade.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import time
5
6
  from typing import TYPE_CHECKING, TypeVar
6
7
 
7
8
  from bitvavo_client.auth.rate_limit import RateLimitManager
@@ -50,17 +51,67 @@ class BitvavoClient:
50
51
  self.private = PrivateAPI(self.http, preferred_model=preferred_model, default_schema=default_schema)
51
52
 
52
53
  # Configure API keys if available
54
+ self._api_keys: list[tuple[str, str]] = []
55
+ self._current_key: int = -1
53
56
  self._configure_api_keys()
54
57
 
55
58
  def _configure_api_keys(self) -> None:
56
59
  """Configure API keys for authentication."""
60
+ # Collect keys from settings
57
61
  if self.settings.api_key and self.settings.api_secret:
58
- # Single API key configuration
59
- self.http.configure_key(self.settings.api_key, self.settings.api_secret, 0)
60
- self.rate_limiter.ensure_key(0)
61
- elif self.settings.api_keys:
62
- # Multiple API keys - configure the first one by default
63
- if self.settings.api_keys:
64
- first_key = self.settings.api_keys[0]
65
- self.http.configure_key(first_key["key"], first_key["secret"], 0)
66
- self.rate_limiter.ensure_key(0)
62
+ self._api_keys.append((self.settings.api_key, self.settings.api_secret))
63
+ if self.settings.api_keys:
64
+ self._api_keys.extend((item["key"], item["secret"]) for item in self.settings.api_keys)
65
+
66
+ if not self._api_keys:
67
+ return
68
+
69
+ for idx, (_key, _secret) in enumerate(self._api_keys):
70
+ self.rate_limiter.ensure_key(idx)
71
+
72
+ if self._api_keys:
73
+ self.http.set_key_rotation_callback(self.rotate_key)
74
+
75
+ def rotate_key(self) -> bool:
76
+ """Rotate to the next configured API key if available."""
77
+ if not self._api_keys:
78
+ return False
79
+
80
+ now = int(time.time() * 1000)
81
+
82
+ if self._current_key == -1:
83
+ idx = 0
84
+ if now < self.rate_limiter.get_reset_at(idx):
85
+ self.rate_limiter.sleep_until_reset(idx)
86
+ self.rate_limiter.reset_key(idx)
87
+ self._current_key = idx
88
+ key, secret = self._api_keys[idx]
89
+ self.http.configure_key(key, secret, idx)
90
+ return True
91
+
92
+ if self._current_key < len(self._api_keys) - 1:
93
+ idx = self._current_key + 1
94
+ if now < self.rate_limiter.get_reset_at(idx):
95
+ self.rate_limiter.sleep_until_reset(idx)
96
+ self.rate_limiter.reset_key(idx)
97
+ self._current_key = idx
98
+ key, secret = self._api_keys[idx]
99
+ self.http.configure_key(key, secret, idx)
100
+ return True
101
+
102
+ reset_at = self.rate_limiter.get_reset_at(-1)
103
+ if now < reset_at:
104
+ self.rate_limiter.sleep_until_reset(-1)
105
+ self.rate_limiter.reset_key(-1)
106
+ self._current_key = -1
107
+ self.http.configure_key("", "", -1)
108
+ return True
109
+
110
+ def select_key(self, index: int) -> None:
111
+ """Select a specific API key by index."""
112
+ if not (0 <= index < len(self._api_keys)):
113
+ msg = "API key index out of range"
114
+ raise IndexError(msg)
115
+ self._current_key = index
116
+ key, secret = self._api_keys[index]
117
+ self.http.configure_key(key, secret, index)
@@ -15,6 +15,8 @@ from bitvavo_client.adapters.returns_adapter import (
15
15
  from bitvavo_client.auth.signing import create_signature
16
16
 
17
17
  if TYPE_CHECKING: # pragma: no cover
18
+ from collections.abc import Callable
19
+
18
20
  from bitvavo_client.auth.rate_limit import RateLimitManager
19
21
  from bitvavo_client.core.settings import BitvavoSettings
20
22
  from bitvavo_client.core.types import AnyDict
@@ -35,6 +37,7 @@ class HTTPClient:
35
37
  self.key_index: int = -1
36
38
  self.api_key: str = ""
37
39
  self.api_secret: str = ""
40
+ self.key_rotation_callback: Callable[[], bool] | None = None
38
41
 
39
42
  def configure_key(self, key: str, secret: str, index: int) -> None:
40
43
  """Configure API key for authenticated requests.
@@ -48,6 +51,10 @@ class HTTPClient:
48
51
  self.api_secret = secret
49
52
  self.key_index = index
50
53
 
54
+ def set_key_rotation_callback(self, callback: Callable[[], bool]) -> None:
55
+ """Set callback to rotate API keys when rate limit exceeded."""
56
+ self.key_rotation_callback = callback
57
+
51
58
  def request(
52
59
  self,
53
60
  method: str,
@@ -72,7 +79,11 @@ class HTTPClient:
72
79
  """
73
80
  # Check rate limits
74
81
  if not self.rate_limiter.has_budget(self.key_index, weight):
75
- self.rate_limiter.sleep_until_reset(self.key_index)
82
+ rotated = False
83
+ if self.key_rotation_callback:
84
+ rotated = self.key_rotation_callback()
85
+ if not rotated or not self.rate_limiter.has_budget(self.key_index, weight):
86
+ self.rate_limiter.handle_limit(self.key_index, weight)
76
87
 
77
88
  url = f"{self.settings.rest_url}{endpoint}"
78
89
  headers = self._create_auth_headers(method, endpoint, body)