bitvavo-api-upgraded 4.1.1__tar.gz → 4.2.0__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.
Files changed (39) hide show
  1. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/PKG-INFO +5 -4
  2. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/README.md +4 -3
  3. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/pyproject.toml +3 -3
  4. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_api_upgraded/dataframe_utils.py +1 -1
  5. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/adapters/returns_adapter.py +3 -4
  6. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/auth/rate_limit.py +22 -1
  7. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/core/model_preferences.py +20 -2
  8. bitvavo_api_upgraded-4.2.0/src/bitvavo_client/endpoints/base.py +305 -0
  9. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/endpoints/private.py +35 -230
  10. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/endpoints/public.py +23 -130
  11. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/facade.py +35 -9
  12. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/transport/http.py +12 -1
  13. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_api_upgraded/__init__.py +0 -0
  14. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_api_upgraded/bitvavo.py +0 -0
  15. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_api_upgraded/helper_funcs.py +0 -0
  16. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_api_upgraded/py.typed +0 -0
  17. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_api_upgraded/settings.py +0 -0
  18. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_api_upgraded/type_aliases.py +0 -0
  19. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/__init__.py +0 -0
  20. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/adapters/__init__.py +0 -0
  21. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/auth/__init__.py +0 -0
  22. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/auth/signing.py +0 -0
  23. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/core/__init__.py +0 -0
  24. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/core/errors.py +0 -0
  25. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/core/private_models.py +0 -0
  26. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/core/public_models.py +0 -0
  27. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/core/settings.py +0 -0
  28. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/core/types.py +0 -0
  29. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/core/validation_helpers.py +0 -0
  30. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/df/__init__.py +0 -0
  31. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/df/convert.py +0 -0
  32. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/endpoints/__init__.py +0 -0
  33. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/endpoints/common.py +0 -0
  34. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/py.typed +0 -0
  35. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/schemas/__init__.py +0 -0
  36. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/schemas/private_schemas.py +0 -0
  37. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/schemas/public_schemas.py +0 -0
  38. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/transport/__init__.py +0 -0
  39. {bitvavo_api_upgraded-4.1.1 → bitvavo_api_upgraded-4.2.0}/src/bitvavo_client/ws/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: bitvavo-api-upgraded
3
- Version: 4.1.1
3
+ Version: 4.2.0
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.DATAFRAME)
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
- df_data = client.public.markets(model=pl.DataFrame)
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.DATAFRAME)
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
- df_data = client.public.markets(model=pl.DataFrame)
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.1"
9
+ version = "4.2.0"
10
10
  description = "A unit-tested fork of the Bitvavo API"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -89,7 +89,7 @@ dev-dependencies = [
89
89
  "mdformat>=0.7.11", # for formatting markdown (.md) documents
90
90
  "mkdocs>=1.2.3", # for markdown documentation
91
91
  "mypy>=1.13", # amazing linter
92
- "narwhals>=2.0.0", # unified dataframe API for testing
92
+ "narwhals[dask,duckdb,pandas,polars,pyarrow]>=2.0.0", # unified dataframe API with all integrations for testing
93
93
  "pandas-stubs>=2.2.2.0", # pandas typing for mypy
94
94
  "pandas>=2.0.0", # pandas support for testing
95
95
  "polars>=1.0.0", # polars support for testing
@@ -108,7 +108,7 @@ dev-dependencies = [
108
108
  ]
109
109
 
110
110
  [tool.bumpversion]
111
- current_version = "4.1.1"
111
+ current_version = "4.2.0"
112
112
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
113
113
  serialize = ["{major}.{minor}.{patch}"]
114
114
  search = "{current_version}"
@@ -31,7 +31,7 @@ def is_library_available(library_name: str) -> bool:
31
31
  "cudf": "cudf",
32
32
  "modin": "modin.pandas",
33
33
  "pyarrow": "pyarrow",
34
- "dask": "dask.dataframe",
34
+ "dask": "dask",
35
35
  "duckdb": "duckdb",
36
36
  "ibis": "ibis",
37
37
  "pyspark": "pyspark.sql",
@@ -285,13 +285,12 @@ def decode_response_result( # noqa: C901 (complexity)
285
285
  # I don't like the complexity of this piece, but it's needed because the data from ticker_book may return an
286
286
  # int when it should be a float... Why is their DB such a damned mess? Fuck me, man...
287
287
  try:
288
- import polars as pl # noqa: PLC0415
289
-
290
- if model is pl.DataFrame:
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):
291
290
  parsed = model(data, schema=schema, strict=False) # type: ignore[arg-type]
292
291
  else:
293
292
  parsed = model(data, schema=schema) # type: ignore[arg-type]
294
- except ImportError:
293
+ except (ImportError, AttributeError):
295
294
  parsed = model(data, schema=schema) # type: ignore[arg-type]
296
295
  return Success(parsed)
297
296
  except Exception as exc: # noqa: BLE001
@@ -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,19 @@ 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
  """
22
37
  self.state: dict[int, dict[str, int]] = {-1: {"remaining": default_remaining, "resetAt": 0}}
23
38
  self.buffer: int = buffer
24
39
 
40
+ self._strategy: RateLimitStrategy = strategy or DefaultRateLimitStrategy()
41
+
25
42
  def ensure_key(self, idx: int) -> None:
26
43
  """Ensure a key index exists in the state."""
27
44
  if idx not in self.state:
@@ -79,6 +96,10 @@ class RateLimitManager:
79
96
  ms_left = max(0, self.state[idx]["resetAt"] - now)
80
97
  time.sleep(ms_left / 1000 + 1)
81
98
 
99
+ def handle_limit(self, idx: int, weight: int) -> None:
100
+ """Invoke the configured strategy when rate limit is exceeded."""
101
+ self._strategy(self, idx, weight)
102
+
82
103
  def get_remaining(self, idx: int) -> int:
83
104
  """Get remaining rate limit for key index.
84
105
 
@@ -23,8 +23,26 @@ class ModelPreference(StrEnum):
23
23
  # Return raw Python data structures (dict/list)
24
24
  RAW = "raw"
25
25
 
26
- # Return Polars DataFrame (requires schema)
27
- DATAFRAME = "dataframe"
26
+ # Return Polars DataFrame
27
+ POLARS = "polars"
28
+
29
+ # Return Pandas DataFrame
30
+ PANDAS = "pandas"
31
+
32
+ # Return PyArrow Table
33
+ PYARROW = "pyarrow"
34
+
35
+ # Return Dask DataFrame
36
+ DASK = "dask"
37
+
38
+ # Return Modin DataFrame
39
+ MODIN = "modin"
40
+
41
+ # Return CuDF DataFrame (GPU accelerated)
42
+ CUDF = "cudf"
43
+
44
+ # Return Ibis DataFrame
45
+ IBIS = "ibis"
28
46
 
29
47
  # Return appropriate Pydantic model for each endpoint
30
48
  PYDANTIC = "pydantic"
@@ -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,16 +2,19 @@
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
- import polars as pl
10
8
  from returns.result import Failure, Result, Success
11
9
 
12
10
  from bitvavo_client.adapters.returns_adapter import BitvavoError
13
11
  from bitvavo_client.core import private_models
14
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
+ )
15
18
  from bitvavo_client.endpoints.common import create_postfix, default
16
19
  from bitvavo_client.schemas.private_schemas import DEFAULT_SCHEMAS
17
20
 
@@ -22,87 +25,26 @@ if TYPE_CHECKING: # pragma: no cover
22
25
  T = TypeVar("T")
23
26
 
24
27
 
25
- def _extract_dataframe_data(data: Any, *, items_key: str | None = None) -> list[dict] | dict:
26
- """Extract the meaningful data for DataFrame creation from API responses.
27
-
28
- Args:
29
- data: Raw API response data
30
- items_key: Key to extract from nested response (e.g., 'items' for paginated responses)
31
-
32
- Returns:
33
- List of dicts or single dict suitable for DataFrame creation
34
-
35
- Raises:
36
- ValueError: If data format is unexpected
37
- """
38
- if items_key and isinstance(data, dict) and items_key in data:
39
- # Extract nested items (e.g., transaction_history['items'])
40
- items = data[items_key]
41
- if not isinstance(items, list):
42
- msg = f"Expected {items_key} to be a list, got {type(items)}"
43
- raise ValueError(msg)
44
- return items
45
- if isinstance(data, list):
46
- # Direct list response (e.g., balance, trades)
47
- return data
48
- if isinstance(data, dict):
49
- # Single dict response - wrap in list for DataFrame
50
- return [data]
51
-
52
- msg = f"Unexpected data type for DataFrame creation: {type(data)}"
53
- raise ValueError(msg)
54
-
55
-
56
- def _create_dataframe_from_data(
57
- data: Any, *, items_key: str | None = None, empty_schema: dict[str, Any] | None = None
58
- ) -> Result[pl.DataFrame, BitvavoError]:
59
- """Create a DataFrame from API response data.
60
-
61
- Args:
62
- data: Raw API response data
63
- items_key: Key to extract from nested response (optional)
64
- empty_schema: Schema to use for empty DataFrames and type casting
65
-
66
- Returns:
67
- Result containing DataFrame or error
68
- """
69
- try:
70
- df_data = _extract_dataframe_data(data, items_key=items_key)
71
-
72
- if df_data:
73
- # Create DataFrame with flexible schema first
74
- df = pl.DataFrame(df_data, strict=False)
75
-
76
- # Apply schema casting if provided
77
- if empty_schema:
78
- # Cast columns that exist in both DataFrame and schema
79
- for col, expected_dtype in empty_schema.items():
80
- if col in df.columns:
81
- with contextlib.suppress(pl.exceptions.PolarsError, ValueError):
82
- df = df.with_columns(pl.col(col).cast(expected_dtype))
83
-
84
- return Success(df) # type: ignore[return-value]
85
-
86
- # Create empty DataFrame with provided schema or minimal default
87
- if empty_schema is None:
88
- empty_schema = {"id": pl.String} # Minimal default schema
89
- df = pl.DataFrame([], schema=empty_schema)
90
- return Success(df) # type: ignore[return-value]
91
-
92
- except (ValueError, TypeError, pl.exceptions.PolarsError) as exc:
93
- error = BitvavoError(
94
- http_status=500,
95
- error_code=-1,
96
- reason="DataFrame creation failed",
97
- message=f"Failed to create DataFrame from API response: {exc}",
98
- raw={"data_type": type(data).__name__, "data_sample": str(data)[:200]},
99
- )
100
- return Failure(error)
101
-
102
-
103
- class PrivateAPI:
28
+ class PrivateAPI(BaseAPI):
104
29
  """Handles all private Bitvavo API endpoints requiring authentication."""
105
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
+
106
48
  def __init__(
107
49
  self,
108
50
  http_client: HTTPClient,
@@ -110,147 +52,8 @@ class PrivateAPI:
110
52
  preferred_model: ModelPreference | str | None = None,
111
53
  default_schema: dict | None = None,
112
54
  ) -> None:
113
- """Initialize private API handler.
114
-
115
- Args:
116
- http_client: HTTP client for making requests
117
- preferred_model: Preferred model format for responses
118
- default_schema: Default schema for DataFrame conversion
119
- """
120
- self.http: HTTPClient = http_client
121
- self.preferred_model = ModelPreference(preferred_model) if preferred_model else None
122
- self.default_schema = default_schema
123
-
124
- def _get_effective_model(
125
- self,
126
- endpoint_type: str,
127
- model: type[T] | Any | None,
128
- schema: dict | None,
129
- ) -> tuple[type[T] | Any | None, dict | None]:
130
- """Get the effective model and schema to use for a request.
131
-
132
- Args:
133
- endpoint_type: Type of endpoint (e.g., 'account', 'balance', 'orders')
134
- model: Model explicitly passed to method (overrides preference)
135
- schema: Schema explicitly passed to method
136
-
137
- Returns:
138
- Tuple of (effective_model, effective_schema)
139
-
140
- Raises:
141
- ValueError: If endpoint_type is not recognized
142
- """
143
- # If model is explicitly provided, use it
144
- if model is not None:
145
- return model, schema
146
-
147
- # If no preferred model is set, return None (raw response)
148
- if self.preferred_model is None:
149
- return None, schema
150
-
151
- # Apply preference based on enum value
152
- if self.preferred_model == ModelPreference.RAW:
153
- return Any, schema
154
-
155
- if self.preferred_model == ModelPreference.DATAFRAME:
156
- # Validate endpoint type
157
- if endpoint_type not in list(DEFAULT_SCHEMAS.keys()):
158
- msg = f"Invalid endpoint_type '{endpoint_type}'. Valid types: {sorted(DEFAULT_SCHEMAS)}"
159
- raise ValueError(msg)
160
- # Use the provided schema, fallback to instance default, then to endpoint-specific default
161
- effective_schema = schema or self.default_schema or DEFAULT_SCHEMAS.get(endpoint_type)
162
- # Convert to dict if it's a Mapping but not already a dict
163
- if effective_schema is not None and not isinstance(effective_schema, dict):
164
- effective_schema = dict(effective_schema)
165
- return pl.DataFrame, effective_schema
166
-
167
- if self.preferred_model == ModelPreference.PYDANTIC:
168
- # Map endpoint types to appropriate Pydantic models
169
- endpoint_model_map = {
170
- "account": private_models.Account,
171
- "balance": private_models.Balances,
172
- "orders": private_models.Orders,
173
- "order": private_models.Order,
174
- "trade_history": private_models.Trades,
175
- "transaction_history": private_models.TransactionHistory,
176
- "fees": private_models.Fees,
177
- "deposit_history": private_models.DepositHistories,
178
- "deposit": private_models.Deposit,
179
- "withdrawals": private_models.Withdrawals,
180
- "withdraw": private_models.WithdrawResponse,
181
- "cancel_order": private_models.CancelOrderResponse,
182
- }
183
- if endpoint_type not in endpoint_model_map:
184
- msg = f"No Pydantic model defined for endpoint_type '{endpoint_type}'. Add it to endpoint_model_map."
185
- raise ValueError(msg)
186
- return endpoint_model_map[endpoint_type], schema
187
-
188
- # Default case (AUTO or unknown)
189
- return None, schema
190
-
191
- def _convert_raw_result(
192
- self,
193
- raw_result: Result[Any, BitvavoError | httpx.HTTPError],
194
- endpoint_type: str,
195
- model: type[T] | Any | None,
196
- schema: dict | None,
197
- *,
198
- items_key: str | None = None,
199
- ) -> Result[Any, BitvavoError | httpx.HTTPError]:
200
- """Convert raw API result to the desired model format.
201
-
202
- Args:
203
- raw_result: Raw result from HTTP client
204
- endpoint_type: Type of endpoint (e.g., 'account', 'balance', 'orders')
205
- model: Model explicitly passed to method (overrides preference)
206
- schema: Schema explicitly passed to method
207
- items_key: Key to extract from nested response for DataFrames
208
-
209
- Returns:
210
- Result with converted data or original error
211
- """
212
- # If the raw result is an error, return it as-is
213
- if isinstance(raw_result, Failure):
214
- return raw_result
215
-
216
- # Get the effective model and schema to use
217
- effective_model, effective_schema = self._get_effective_model(endpoint_type, model, schema)
218
-
219
- # If no conversion needed (raw data requested), return as-is
220
- if effective_model is Any or effective_model is None:
221
- return raw_result
222
-
223
- # Extract the raw data
224
- raw_data = raw_result.unwrap()
225
-
226
- # Handle DataFrames specially
227
- if effective_model is pl.DataFrame:
228
- return _create_dataframe_from_data(raw_data, items_key=items_key, empty_schema=effective_schema)
229
-
230
- # Handle other model types using the same logic as PublicAPI
231
- try:
232
- # Handle different model types
233
- if hasattr(effective_model, "model_validate"):
234
- # Pydantic model
235
- parsed = effective_model.model_validate(raw_data) # type: ignore[misc]
236
- elif effective_schema is None:
237
- # Simple constructor call - this handles dict and other simple types
238
- parsed = effective_model(raw_data) # type: ignore[misc]
239
- else:
240
- # Other models with schema
241
- parsed = effective_model(raw_data, schema=effective_schema) # type: ignore[misc]
242
-
243
- return Success(parsed)
244
- except (ValueError, TypeError, AttributeError) as exc:
245
- # If conversion fails, return a structured error
246
- error = BitvavoError(
247
- http_status=500,
248
- error_code=-1,
249
- reason="Model conversion failed",
250
- message=str(exc),
251
- raw=raw_data if isinstance(raw_data, dict) else {"raw": raw_data},
252
- )
253
- return Failure(error)
55
+ """Initialize private API handler."""
56
+ super().__init__(http_client, preferred_model=preferred_model, default_schema=default_schema)
254
57
 
255
58
  def account(
256
59
  self,
@@ -283,7 +86,7 @@ class PrivateAPI:
283
86
  """
284
87
  # Check if DataFrame is requested - not supported for this endpoint
285
88
  effective_model, effective_schema = self._get_effective_model("account", model, schema)
286
- if effective_model is pl.DataFrame:
89
+ if effective_model in _DATAFRAME_LIBRARY_MAP:
287
90
  msg = "DataFrame model is not supported due to the shape of data"
288
91
  return Failure(TypeError(msg))
289
92
 
@@ -774,7 +577,7 @@ class PrivateAPI:
774
577
 
775
578
  effective_model, effective_schema = self._get_effective_model("deposit", model, schema)
776
579
 
777
- if effective_model is pl.DataFrame:
580
+ if effective_model in _DATAFRAME_LIBRARY_MAP:
778
581
  msg = "DataFrame model is not supported due to the shape of data"
779
582
  return Failure(TypeError(msg))
780
583
 
@@ -850,9 +653,11 @@ class PrivateAPI:
850
653
  effective_schema: dict | None,
851
654
  ) -> Result[Any, BitvavoError]:
852
655
  """Convert transaction items to the desired model format."""
853
- if effective_model is pl.DataFrame:
854
- # Convert items to DataFrame
855
- return _create_dataframe_from_data(items_data, items_key=None, empty_schema=effective_schema)
656
+ if effective_model in _DATAFRAME_LIBRARY_MAP and isinstance(effective_model, ModelPreference):
657
+ # Convert items to DataFrame using the specific preference
658
+ return _create_dataframe_from_data(
659
+ items_data, effective_model, items_key=None, empty_schema=effective_schema
660
+ )
856
661
 
857
662
  if effective_model is Any or effective_model is None:
858
663
  # Raw data - return items list directly
@@ -1083,8 +888,8 @@ class PrivateAPI:
1083
888
  Returns:
1084
889
  Result containing withdrawal result or error
1085
890
  """
1086
- effective_model, effective_schema = self._get_effective_model("withdraw", model, schema)
1087
891
  body = {"symbol": symbol, "amount": amount, "address": address}
1088
892
  if options:
1089
893
  body.update(options)
1090
- 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,14 +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
- import polars as pl
8
- from returns.result import Failure, Success
9
-
10
- from bitvavo_client.adapters.returns_adapter import BitvavoError
11
7
  from bitvavo_client.core import public_models
12
- from bitvavo_client.core.model_preferences import ModelPreference
8
+ from bitvavo_client.endpoints.base import BaseAPI
13
9
  from bitvavo_client.endpoints.common import create_postfix
14
10
  from bitvavo_client.schemas.public_schemas import DEFAULT_SCHEMAS
15
11
 
@@ -31,15 +27,33 @@ if TYPE_CHECKING: # pragma: no cover
31
27
  import httpx
32
28
  from returns.result import Result
33
29
 
30
+ from bitvavo_client.adapters.returns_adapter import BitvavoError
31
+ from bitvavo_client.core.model_preferences import ModelPreference
34
32
  from bitvavo_client.core.types import AnyDict
35
33
  from bitvavo_client.transport.http import HTTPClient
36
34
 
37
35
  T = TypeVar("T")
38
36
 
39
37
 
40
- class PublicAPI:
38
+ class PublicAPI(BaseAPI):
41
39
  """Handles all public Bitvavo API endpoints."""
42
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
+
43
57
  def __init__(
44
58
  self,
45
59
  http_client: HTTPClient,
@@ -47,129 +61,8 @@ class PublicAPI:
47
61
  preferred_model: ModelPreference | str | None = None,
48
62
  default_schema: Mapping[str, object] | None = None,
49
63
  ) -> None:
50
- """Initialize public API handler.
51
-
52
- Args:
53
- http_client: HTTP client for making requests
54
- preferred_model: Preferred model format for responses
55
- default_schema: Default schema for DataFrame conversion
56
- """
57
- self.http: HTTPClient = http_client
58
- self.preferred_model = ModelPreference(preferred_model) if preferred_model else None
59
-
60
- # If using DATAFRAME preference without a default schema, we could provide sensible defaults
61
- # But keep it explicit for now - users can import and use schemas as needed
62
- self.default_schema = default_schema
63
-
64
- def _get_effective_model(
65
- self,
66
- endpoint_type: str,
67
- model: type[T] | Any | None,
68
- schema: Mapping[str, object] | None,
69
- ) -> tuple[type[T] | Any | None, Mapping[str, object] | None]:
70
- """Get the effective model and schema to use for a request.
71
-
72
- Args:
73
- endpoint_type: Type of endpoint (e.g., 'time', 'markets', 'assets')
74
- model: Model explicitly passed to method (overrides preference)
75
- schema: Schema explicitly passed to method
76
-
77
- Returns:
78
- Tuple of (effective_model, effective_schema)
79
- """
80
- # If model is explicitly provided, use it
81
- if model is not None:
82
- return model, schema
83
-
84
- # If no preferred model is set, return Any (raw response)
85
- if self.preferred_model is None:
86
- return Any, schema
87
-
88
- # Apply preference based on enum value
89
- if self.preferred_model == ModelPreference.RAW:
90
- return Any, schema
91
-
92
- if self.preferred_model == ModelPreference.DATAFRAME:
93
- # Use the provided schema, fallback to instance default, then to endpoint-specific default
94
- effective_schema = schema or self.default_schema or DEFAULT_SCHEMAS.get(endpoint_type)
95
- return pl.DataFrame, effective_schema
96
-
97
- if self.preferred_model == ModelPreference.PYDANTIC:
98
- # Map endpoint types to appropriate Pydantic models
99
- endpoint_model_map = {
100
- "time": public_models.ServerTime,
101
- "markets": public_models.Markets,
102
- "assets": public_models.Assets,
103
- "book": public_models.OrderBook,
104
- "trades": public_models.Trades,
105
- "candles": public_models.Candles,
106
- "ticker_price": public_models.TickerPrices,
107
- "ticker_book": public_models.TickerBooks,
108
- "ticker_24h": public_models.Ticker24hs,
109
- "report_book": public_models.OrderBookReport,
110
- "report_trades": public_models.TradesReport,
111
- }
112
- return endpoint_model_map.get(endpoint_type, dict), schema
113
-
114
- # Default case (AUTO or unknown)
115
- return None, schema
116
-
117
- def _convert_raw_result(
118
- self,
119
- raw_result: Result[Any, BitvavoError | httpx.HTTPError],
120
- endpoint_type: str,
121
- model: type[T] | Any | None,
122
- schema: Mapping[str, object] | None,
123
- ) -> Result[Any, BitvavoError | httpx.HTTPError]:
124
- """Convert raw API result to the desired model format.
125
-
126
- Args:
127
- raw_result: Raw result from HTTP client
128
- endpoint_type: Type of endpoint (e.g., 'time', 'markets', 'assets')
129
- model: Model explicitly passed to method (overrides preference)
130
- schema: Schema explicitly passed to method
131
-
132
- Returns:
133
- Result with converted data or original error
134
- """
135
- # If the raw result is an error, return it as-is
136
- if isinstance(raw_result, Failure):
137
- return raw_result
138
-
139
- # Get the effective model and schema to use
140
- effective_model, effective_schema = self._get_effective_model(endpoint_type, model, schema)
141
-
142
- # If no conversion needed (raw data requested), return as-is
143
- if effective_model is Any or effective_model is None:
144
- return raw_result
145
-
146
- # Extract the raw data
147
- raw_data = raw_result.unwrap()
148
-
149
- # Perform conversion using the same logic as the returns adapter
150
- try:
151
- # Handle different model types
152
- if hasattr(effective_model, "model_validate"):
153
- # Pydantic model
154
- parsed = effective_model.model_validate(raw_data) # type: ignore[misc]
155
- elif effective_schema is None:
156
- # Simple constructor call - this handles dict and other simple types
157
- parsed = effective_model(raw_data) # type: ignore[misc]
158
- else:
159
- # DataFrame with schema - use type ignoring for now to get working
160
- parsed = effective_model(raw_data, schema=effective_schema, strict=False) # type: ignore[misc]
161
-
162
- return Success(parsed)
163
- except (ValueError, TypeError, AttributeError) as exc:
164
- # If conversion fails, return a structured error
165
- error = BitvavoError(
166
- http_status=500,
167
- error_code=-1,
168
- reason="Model conversion failed",
169
- message=str(exc),
170
- raw=raw_data if isinstance(raw_data, dict) else {"raw": raw_data},
171
- )
172
- return Failure(error)
64
+ """Initialize public API handler."""
65
+ super().__init__(http_client, preferred_model=preferred_model, default_schema=default_schema)
173
66
 
174
67
  def time(
175
68
  self,
@@ -50,17 +50,43 @@ class BitvavoClient:
50
50
  self.private = PrivateAPI(self.http, preferred_model=preferred_model, default_schema=default_schema)
51
51
 
52
52
  # Configure API keys if available
53
+ self._api_keys: list[tuple[str, str]] = []
54
+ self._current_key: int = 0
53
55
  self._configure_api_keys()
54
56
 
55
57
  def _configure_api_keys(self) -> None:
56
58
  """Configure API keys for authentication."""
59
+ # Collect keys from settings
57
60
  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)
61
+ self._api_keys.append((self.settings.api_key, self.settings.api_secret))
62
+ if self.settings.api_keys:
63
+ self._api_keys.extend((item["key"], item["secret"]) for item in self.settings.api_keys)
64
+
65
+ if not self._api_keys:
66
+ return
67
+
68
+ for idx, (_key, _secret) in enumerate(self._api_keys):
69
+ self.rate_limiter.ensure_key(idx)
70
+
71
+ first_key = self._api_keys[0]
72
+ self.http.configure_key(first_key[0], first_key[1], 0)
73
+ if len(self._api_keys) > 1:
74
+ self.http.set_key_rotation_callback(self.rotate_key)
75
+
76
+ def rotate_key(self) -> bool:
77
+ """Rotate to the next configured API key if available."""
78
+ if len(self._api_keys) <= 1:
79
+ return False
80
+ self._current_key = (self._current_key + 1) % len(self._api_keys)
81
+ key, secret = self._api_keys[self._current_key]
82
+ self.http.configure_key(key, secret, self._current_key)
83
+ return True
84
+
85
+ def select_key(self, index: int) -> None:
86
+ """Select a specific API key by index."""
87
+ if not (0 <= index < len(self._api_keys)):
88
+ msg = "API key index out of range"
89
+ raise IndexError(msg)
90
+ self._current_key = index
91
+ key, secret = self._api_keys[index]
92
+ 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)