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.
- {bitvavo_api_upgraded-4.1.2.dist-info → bitvavo_api_upgraded-4.2.1.dist-info}/METADATA +1 -1
- {bitvavo_api_upgraded-4.1.2.dist-info → bitvavo_api_upgraded-4.2.1.dist-info}/RECORD +9 -8
- bitvavo_client/auth/rate_limit.py +29 -1
- bitvavo_client/endpoints/base.py +305 -0
- bitvavo_client/endpoints/private.py +28 -365
- bitvavo_client/endpoints/public.py +23 -220
- bitvavo_client/facade.py +60 -9
- bitvavo_client/transport/http.py +12 -1
- {bitvavo_api_upgraded-4.1.2.dist-info → bitvavo_api_upgraded-4.2.1.dist-info}/WHEEL +0 -0
@@ -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=
|
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=
|
27
|
-
bitvavo_client/endpoints/public.py,sha256=
|
28
|
-
bitvavo_client/facade.py,sha256=
|
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=
|
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.
|
37
|
-
bitvavo_api_upgraded-4.1.
|
38
|
-
bitvavo_api_upgraded-4.1.
|
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
|
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
|
-
|
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
|
-
|
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.
|
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
|
-
|
59
|
-
|
60
|
-
self.
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
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)
|
bitvavo_client/transport/http.py
CHANGED
@@ -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
|
-
|
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)
|
File without changes
|