bitvavo-api-upgraded 4.1.0__py3-none-any.whl → 4.1.2__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.
Files changed (33) hide show
  1. bitvavo_api_upgraded/dataframe_utils.py +1 -1
  2. {bitvavo_api_upgraded-4.1.0.dist-info → bitvavo_api_upgraded-4.1.2.dist-info}/METADATA +5 -4
  3. bitvavo_api_upgraded-4.1.2.dist-info/RECORD +38 -0
  4. bitvavo_client/__init__.py +9 -0
  5. bitvavo_client/adapters/__init__.py +1 -0
  6. bitvavo_client/adapters/returns_adapter.py +362 -0
  7. bitvavo_client/auth/__init__.py +1 -0
  8. bitvavo_client/auth/rate_limit.py +104 -0
  9. bitvavo_client/auth/signing.py +33 -0
  10. bitvavo_client/core/__init__.py +1 -0
  11. bitvavo_client/core/errors.py +17 -0
  12. bitvavo_client/core/model_preferences.py +51 -0
  13. bitvavo_client/core/private_models.py +886 -0
  14. bitvavo_client/core/public_models.py +1087 -0
  15. bitvavo_client/core/settings.py +52 -0
  16. bitvavo_client/core/types.py +11 -0
  17. bitvavo_client/core/validation_helpers.py +90 -0
  18. bitvavo_client/df/__init__.py +1 -0
  19. bitvavo_client/df/convert.py +86 -0
  20. bitvavo_client/endpoints/__init__.py +1 -0
  21. bitvavo_client/endpoints/common.py +88 -0
  22. bitvavo_client/endpoints/private.py +1232 -0
  23. bitvavo_client/endpoints/public.py +748 -0
  24. bitvavo_client/facade.py +66 -0
  25. bitvavo_client/py.typed +0 -0
  26. bitvavo_client/schemas/__init__.py +50 -0
  27. bitvavo_client/schemas/private_schemas.py +191 -0
  28. bitvavo_client/schemas/public_schemas.py +149 -0
  29. bitvavo_client/transport/__init__.py +1 -0
  30. bitvavo_client/transport/http.py +159 -0
  31. bitvavo_client/ws/__init__.py +1 -0
  32. bitvavo_api_upgraded-4.1.0.dist-info/RECORD +0 -10
  33. {bitvavo_api_upgraded-4.1.0.dist-info → bitvavo_api_upgraded-4.1.2.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1232 @@
1
+ """Private API endpoints that require authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from typing import TYPE_CHECKING, Any, TypeVar
7
+
8
+ import httpx
9
+ from returns.result import Failure, Result, Success
10
+
11
+ from bitvavo_client.adapters.returns_adapter import BitvavoError
12
+ from bitvavo_client.core import private_models
13
+ from bitvavo_client.core.model_preferences import ModelPreference
14
+ from bitvavo_client.endpoints.common import create_postfix, default
15
+ from bitvavo_client.schemas.private_schemas import DEFAULT_SCHEMAS
16
+
17
+ if TYPE_CHECKING: # pragma: no cover
18
+ from bitvavo_client.core.types import AnyDict
19
+ from bitvavo_client.transport.http import HTTPClient
20
+
21
+ T = TypeVar("T")
22
+
23
+
24
+ def _extract_dataframe_data(data: Any, *, items_key: str | None = None) -> list[dict] | dict:
25
+ """Extract the meaningful data for DataFrame creation from API responses.
26
+
27
+ Args:
28
+ data: Raw API response data
29
+ items_key: Key to extract from nested response (e.g., 'items' for paginated responses)
30
+
31
+ Returns:
32
+ List of dicts or single dict suitable for DataFrame creation
33
+
34
+ Raises:
35
+ ValueError: If data format is unexpected
36
+ """
37
+ if items_key and isinstance(data, dict) and items_key in data:
38
+ # Extract nested items (e.g., transaction_history['items'])
39
+ items = data[items_key]
40
+ if not isinstance(items, list):
41
+ msg = f"Expected {items_key} to be a list, got {type(items)}"
42
+ raise ValueError(msg)
43
+ return items
44
+ if isinstance(data, list):
45
+ # Direct list response (e.g., balance, trades)
46
+ return data
47
+ if isinstance(data, dict):
48
+ # Single dict response - wrap in list for DataFrame
49
+ return [data]
50
+
51
+ msg = f"Unexpected data type for DataFrame creation: {type(data)}"
52
+ raise ValueError(msg)
53
+
54
+
55
+ # DataFrames preference to library mapping
56
+ _DATAFRAME_LIBRARY_MAP = {
57
+ ModelPreference.POLARS: ("polars", "pl.DataFrame"),
58
+ ModelPreference.PANDAS: ("pandas", "pd.DataFrame"),
59
+ ModelPreference.PYARROW: ("pyarrow", "pa.Table"),
60
+ ModelPreference.DASK: ("dask", "dd.DataFrame"),
61
+ ModelPreference.MODIN: ("modin", "mpd.DataFrame"),
62
+ ModelPreference.CUDF: ("cudf", "cudf.DataFrame"),
63
+ ModelPreference.IBIS: ("ibis", "ibis.Table"),
64
+ }
65
+
66
+
67
+ def _get_dataframe_constructor(preference: ModelPreference) -> tuple[Any, str]:
68
+ """Get the appropriate dataframe constructor and library name based on preference.
69
+
70
+ Args:
71
+ preference: ModelPreference enum value
72
+
73
+ Returns:
74
+ Tuple of (constructor_class, library_name)
75
+
76
+ Raises:
77
+ ImportError: If required library is not available
78
+ ValueError: If preference is not a supported dataframe type
79
+ """
80
+ if preference not in _DATAFRAME_LIBRARY_MAP:
81
+ msg = f"Unsupported dataframe preference: {preference}"
82
+ raise ValueError(msg)
83
+
84
+ library_name, _ = _DATAFRAME_LIBRARY_MAP[preference]
85
+
86
+ try:
87
+ if preference == ModelPreference.POLARS:
88
+ import polars as pl # noqa: PLC0415
89
+
90
+ return pl.DataFrame, library_name
91
+ if preference == ModelPreference.PANDAS:
92
+ import pandas as pd # noqa: PLC0415
93
+
94
+ return pd.DataFrame, library_name
95
+ if preference == ModelPreference.PYARROW:
96
+ import pyarrow as pa # noqa: PLC0415
97
+
98
+ return pa.Table.from_pylist, library_name
99
+ if preference == ModelPreference.DASK:
100
+ import dask.dataframe as dd # noqa: PLC0415
101
+ import pandas as pd # noqa: PLC0415
102
+
103
+ return lambda data, **kwargs: dd.from_pandas(pd.DataFrame(data), npartitions=1), library_name
104
+ if preference == ModelPreference.MODIN:
105
+ import modin.pandas as mpd # noqa: PLC0415
106
+
107
+ return mpd.DataFrame, library_name
108
+ if preference == ModelPreference.CUDF:
109
+ import cudf # noqa: PLC0415
110
+
111
+ return cudf.DataFrame, library_name
112
+ # ModelPreference.IBIS
113
+ import ibis # noqa: PLC0415
114
+
115
+ return lambda data, **kwargs: ibis.memtable(data), library_name
116
+ except ImportError as e:
117
+ msg = f"{library_name} is not installed. Install with appropriate package manager."
118
+ raise ImportError(msg) from e
119
+
120
+
121
+ def _create_dataframe_from_data(
122
+ data: Any, preference: ModelPreference, *, items_key: str | None = None, empty_schema: dict[str, Any] | None = None
123
+ ) -> Result[Any, BitvavoError]:
124
+ """Create a DataFrame from API response data using the specified preference.
125
+
126
+ Args:
127
+ data: Raw API response data
128
+ preference: ModelPreference enum value indicating which library to use
129
+ items_key: Key to extract from nested response (optional)
130
+ empty_schema: Schema to use for empty DataFrames and type casting
131
+
132
+ Returns:
133
+ Result containing DataFrame or error
134
+ """
135
+ try:
136
+ df_data = _extract_dataframe_data(data, items_key=items_key)
137
+ constructor, library_name = _get_dataframe_constructor(preference)
138
+
139
+ if df_data:
140
+ # Create DataFrame using appropriate constructor
141
+ df = _create_dataframe_with_constructor(constructor, library_name, df_data, empty_schema)
142
+ return Success(df) # type: ignore[return-value]
143
+
144
+ # Create empty DataFrame
145
+ df = _create_empty_dataframe(constructor, library_name, empty_schema)
146
+ return Success(df) # type: ignore[return-value]
147
+
148
+ except (ValueError, TypeError, ImportError) as exc:
149
+ error = BitvavoError(
150
+ http_status=500,
151
+ error_code=-1,
152
+ reason="DataFrame creation failed",
153
+ message=f"Failed to create DataFrame from API response: {exc}",
154
+ raw={"data_type": type(data).__name__, "data_sample": str(data)[:200]},
155
+ )
156
+ return Failure(error)
157
+
158
+
159
+ def _create_dataframe_with_constructor(
160
+ constructor: Any, library_name: str, df_data: list | dict, empty_schema: dict | None
161
+ ) -> Any:
162
+ """Create DataFrame with data using the appropriate constructor."""
163
+ if library_name == "polars":
164
+ df = constructor(df_data, strict=False)
165
+ if empty_schema:
166
+ df = _apply_polars_schema(df, empty_schema)
167
+ return df
168
+ if library_name in ("pandas", "modin", "cudf"):
169
+ df = constructor(df_data)
170
+ if empty_schema:
171
+ df = _apply_pandas_like_schema(df, empty_schema)
172
+ return df
173
+ if library_name == "pyarrow" or library_name == "dask":
174
+ return constructor(df_data)
175
+ # ibis
176
+ return constructor(df_data)
177
+
178
+
179
+ def _create_empty_dataframe(constructor: Any, library_name: str, empty_schema: dict | None) -> Any:
180
+ """Create empty DataFrame using the appropriate constructor."""
181
+ if empty_schema is None:
182
+ empty_schema = {"id": str} if library_name != "polars" else {"id": "pl.String"}
183
+
184
+ if library_name == "polars":
185
+ import polars as pl # noqa: PLC0415
186
+
187
+ schema = {k: pl.String if v == "pl.String" else v for k, v in empty_schema.items()}
188
+ return constructor([], schema=schema)
189
+ if library_name in ("pandas", "modin", "cudf", "dask") or library_name == "pyarrow":
190
+ return constructor([])
191
+ # ibis
192
+ return constructor([dict.fromkeys(empty_schema.keys())])
193
+
194
+
195
+ def _apply_polars_schema(df: Any, schema: dict) -> Any:
196
+ """Apply schema to polars DataFrame."""
197
+ import polars as pl # noqa: PLC0415
198
+
199
+ for col, expected_dtype in schema.items():
200
+ if col in df.columns:
201
+ with contextlib.suppress(Exception):
202
+ df = df.with_columns(pl.col(col).cast(expected_dtype))
203
+ return df
204
+
205
+
206
+ def _apply_pandas_like_schema(df: Any, schema: dict) -> Any:
207
+ """Apply schema to pandas-like DataFrame."""
208
+ pandas_schema = {}
209
+ for col, dtype in schema.items():
210
+ if col in df.columns:
211
+ if "String" in str(dtype) or "Utf8" in str(dtype):
212
+ pandas_schema[col] = "string"
213
+ elif "Int" in str(dtype):
214
+ pandas_schema[col] = "int64"
215
+ elif "Float" in str(dtype):
216
+ pandas_schema[col] = "float64"
217
+
218
+ if pandas_schema and hasattr(df, "astype"):
219
+ with contextlib.suppress(Exception):
220
+ df = df.astype(pandas_schema)
221
+ return df
222
+
223
+
224
+ class PrivateAPI:
225
+ """Handles all private Bitvavo API endpoints requiring authentication."""
226
+
227
+ def __init__(
228
+ self,
229
+ http_client: HTTPClient,
230
+ *,
231
+ preferred_model: ModelPreference | str | None = None,
232
+ default_schema: dict | None = None,
233
+ ) -> 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)
394
+
395
+ def account(
396
+ self,
397
+ *,
398
+ model: type[T] | Any | None = None,
399
+ schema: dict | None = None,
400
+ ) -> Result[T, BitvavoError | httpx.HTTPError | TypeError]:
401
+ """Get account information including fees and capabilities.
402
+
403
+ Endpoint: GET /v2/account
404
+ Rate limit weight: 1
405
+
406
+ Args:
407
+ model: Optional Pydantic model to validate response
408
+
409
+ Returns:
410
+ Result containing account information including fees and capabilities:
411
+ {
412
+ "fees": {
413
+ "tier": 0,
414
+ "volume": "0.00",
415
+ "maker": "0.0015",
416
+ "taker": "0.0025"
417
+ },
418
+ "capabilities": [
419
+ "buy", "sell", "depositCrypto", "depositFiat",
420
+ "withdrawCrypto", "withdrawFiat"
421
+ ]
422
+ }
423
+ """
424
+ # Check if DataFrame is requested - not supported for this endpoint
425
+ effective_model, effective_schema = self._get_effective_model("account", model, schema)
426
+ if effective_model in _DATAFRAME_LIBRARY_MAP:
427
+ msg = "DataFrame model is not supported due to the shape of data"
428
+ return Failure(TypeError(msg))
429
+
430
+ # Get raw data from API
431
+ raw_result = self.http.request("GET", "/account", weight=1)
432
+ # Convert to desired format
433
+ return self._convert_raw_result(raw_result, "account", model, schema)
434
+
435
+ def balance(
436
+ self,
437
+ options: AnyDict | None = None,
438
+ *,
439
+ model: type[T] | Any | None = None,
440
+ schema: dict | None = None,
441
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
442
+ """Get account balance.
443
+
444
+ Args:
445
+ options: Optional query parameters
446
+ model: Optional Pydantic model to validate response
447
+
448
+ Returns:
449
+ Result containing balance information or error
450
+ """
451
+ postfix = create_postfix(options)
452
+
453
+ # Get raw data from API
454
+ raw_result = self.http.request("GET", f"/balance{postfix}", weight=5)
455
+ # Convert to desired format
456
+ return self._convert_raw_result(raw_result, "balance", model, schema)
457
+
458
+ def place_order(
459
+ self,
460
+ market: str,
461
+ side: str,
462
+ order_type: str,
463
+ operator_id: int,
464
+ body: AnyDict,
465
+ *,
466
+ response_required: bool = True,
467
+ model: type[T] | Any | None = None,
468
+ schema: dict | None = None,
469
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
470
+ """Place a new order.
471
+
472
+ Args:
473
+ market: Market symbol
474
+ side: Order side ('buy' or 'sell')
475
+ order_type: Order type ('market', 'limit', etc.)
476
+ operator_id: Your identifier for the trader or bot within your account
477
+ body: Order parameters (amount, price, etc.)
478
+ response_required: Whether to return full order details (True) or minimal response (False)
479
+
480
+ Returns:
481
+ Order placement result
482
+ """
483
+ effective_model, effective_schema = self._get_effective_model("order", model, schema)
484
+ payload = {
485
+ "market": market,
486
+ "side": side,
487
+ "orderType": order_type,
488
+ "operatorId": operator_id,
489
+ "responseRequired": response_required,
490
+ **body,
491
+ }
492
+ return self.http.request("POST", "/order", body=payload, weight=1)
493
+
494
+ def get_order(
495
+ self,
496
+ market: str,
497
+ order_id: str,
498
+ *,
499
+ model: type[T] | Any | None = None,
500
+ schema: dict | None = None,
501
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
502
+ """Get order by ID.
503
+
504
+ Args:
505
+ market: Market symbol
506
+ order_id: Order ID
507
+ model: Optional Pydantic model to validate response
508
+
509
+ Returns:
510
+ Result containing order information or error
511
+ """
512
+ # Get raw data from API
513
+ raw_result = self.http.request(
514
+ "GET",
515
+ f"/{market}/order",
516
+ body={"orderId": order_id},
517
+ weight=1,
518
+ )
519
+ # Convert to desired format
520
+ return self._convert_raw_result(raw_result, "order", model, schema)
521
+
522
+ def update_order(
523
+ self,
524
+ market: str,
525
+ operator_id: int,
526
+ *,
527
+ order_id: str | None = None,
528
+ client_order_id: str | None = None,
529
+ amount: str | None = None,
530
+ amount_quote: str | None = None,
531
+ amount_remaining: str | None = None,
532
+ price: str | None = None,
533
+ trigger_amount: str | None = None,
534
+ time_in_force: str | None = None,
535
+ self_trade_prevention: str | None = None,
536
+ post_only: bool | None = None,
537
+ response_required: bool | None = None,
538
+ model: type[T] | Any | None = None,
539
+ schema: dict | None = None,
540
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
541
+ """Update an existing limit or trigger order.
542
+
543
+ Args:
544
+ market: Market symbol (e.g., 'BTC-EUR')
545
+ operator_id: Your identifier for the trader or bot within your account
546
+ order_id: Bitvavo identifier of the order to update (required if client_order_id not provided)
547
+ client_order_id: Your identifier of the order to update (required if order_id not provided)
548
+ amount: Amount of base currency to update
549
+ amount_quote: Amount of quote currency (market orders only)
550
+ amount_remaining: Remaining amount of base currency to update
551
+ price: Price for limit orders
552
+ trigger_amount: Trigger price for stop orders
553
+ time_in_force: Time in force ('GTC', 'IOC', 'FOK')
554
+ self_trade_prevention: Self-trade prevention
555
+ ('decrementAndCancel', 'cancelOldest', 'cancelNewest', 'cancelBoth')
556
+ post_only: Whether order should only be maker
557
+ response_required: Whether to return full response or just status code
558
+ model: Optional Pydantic model to validate response
559
+ schema: Optional schema for DataFrame conversion
560
+
561
+ Returns:
562
+ Result containing updated order information or error
563
+
564
+ Note:
565
+ - You must set either order_id or client_order_id
566
+ - If both are set, client_order_id takes precedence
567
+ - Updates are faster than canceling and creating new orders
568
+ - Only works with limit or trigger orders
569
+ """
570
+ if not order_id and not client_order_id:
571
+ error = BitvavoError(
572
+ http_status=400,
573
+ error_code=203,
574
+ reason="Missing or incompatible parameters in your request.",
575
+ message="Either order_id or client_order_id must be provided",
576
+ raw={"provided": {"order_id": order_id, "client_order_id": client_order_id}},
577
+ )
578
+ return Failure(error)
579
+
580
+ effective_model, effective_schema = self._get_effective_model("order", model, schema)
581
+ payload = self._build_update_order_payload(
582
+ market,
583
+ operator_id,
584
+ order_id,
585
+ client_order_id,
586
+ amount,
587
+ amount_quote,
588
+ amount_remaining,
589
+ price,
590
+ trigger_amount,
591
+ time_in_force,
592
+ self_trade_prevention,
593
+ post_only=post_only,
594
+ response_required=response_required,
595
+ )
596
+
597
+ return self.http.request("PUT", "/order", body=payload, weight=1)
598
+
599
+ def _build_update_order_payload(
600
+ self,
601
+ market: str,
602
+ operator_id: int,
603
+ order_id: str | None,
604
+ client_order_id: str | None,
605
+ amount: str | None,
606
+ amount_quote: str | None,
607
+ amount_remaining: str | None,
608
+ price: str | None,
609
+ trigger_amount: str | None,
610
+ time_in_force: str | None,
611
+ self_trade_prevention: str | None,
612
+ *,
613
+ post_only: bool | None,
614
+ response_required: bool | None,
615
+ ) -> dict[str, Any]:
616
+ """Build the payload for update order request."""
617
+ payload = {
618
+ "market": market,
619
+ "operatorId": operator_id,
620
+ }
621
+
622
+ # Add order identifier - clientOrderId takes precedence if both provided
623
+ if client_order_id:
624
+ payload["clientOrderId"] = client_order_id
625
+ elif order_id:
626
+ payload["orderId"] = order_id
627
+
628
+ # Add optional update parameters
629
+ payload.update(
630
+ {
631
+ key: value
632
+ for key, value in {
633
+ "amount": amount,
634
+ "amountQuote": amount_quote,
635
+ "amountRemaining": amount_remaining,
636
+ "price": price,
637
+ "triggerAmount": trigger_amount,
638
+ "timeInForce": time_in_force,
639
+ "selfTradePrevention": self_trade_prevention,
640
+ "postOnly": post_only,
641
+ "responseRequired": response_required,
642
+ }.items()
643
+ if value is not None
644
+ }
645
+ )
646
+
647
+ return payload
648
+
649
+ def cancel_order(
650
+ self,
651
+ market: str,
652
+ operator_id: int,
653
+ *,
654
+ order_id: str | None = None,
655
+ client_order_id: str | None = None,
656
+ model: type[T] | Any | None = None,
657
+ schema: dict | None = None,
658
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
659
+ """Cancel an order.
660
+
661
+ Args:
662
+ market: Market symbol
663
+ operator_id: Your identifier for the trader or bot within your account
664
+ order_id: Bitvavo identifier of the order (required if client_order_id not provided)
665
+ client_order_id: Your identifier of the order (required if order_id not provided)
666
+ model: Optional Pydantic model to validate response
667
+
668
+ Returns:
669
+ Result containing cancellation result or error
670
+
671
+ Note:
672
+ You must set either order_id or client_order_id. If you set both,
673
+ client_order_id takes precedence as per Bitvavo documentation.
674
+ """
675
+ if not order_id and not client_order_id:
676
+ # Create a validation error using httpx.HTTPError as a fallback
677
+ error = httpx.RequestError("Either order_id or client_order_id must be provided")
678
+ return Failure(error)
679
+
680
+ # Build query parameters
681
+ params = {
682
+ "market": market,
683
+ "operatorId": operator_id,
684
+ }
685
+
686
+ if client_order_id:
687
+ params["clientOrderId"] = client_order_id
688
+ elif order_id:
689
+ params["orderId"] = order_id
690
+
691
+ # Create query string
692
+ postfix = create_postfix(params)
693
+
694
+ effective_model, effective_schema = self._get_effective_model("cancel_order", model, schema)
695
+ return self.http.request(
696
+ "DELETE",
697
+ f"/order{postfix}",
698
+ weight=1,
699
+ )
700
+
701
+ def get_orders(
702
+ self,
703
+ market: str,
704
+ options: AnyDict | None = None,
705
+ *,
706
+ model: type[T] | Any | None = None,
707
+ schema: dict | None = None,
708
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
709
+ """Get orders for a market.
710
+
711
+ Args:
712
+ market: Market symbol (e.g., 'BTC-EUR')
713
+ options: Optional query parameters:
714
+ - limit (int): Max number of orders (1-1000, default: 500)
715
+ - start (int): Unix timestamp in ms to start from
716
+ - end (int): Unix timestamp in ms to end at (max: 8640000000000000)
717
+ - orderIdFrom (str): UUID to start from
718
+ - orderIdTo (str): UUID to end at
719
+ model: Optional Pydantic model to validate response
720
+ schema: Optional schema for DataFrame conversion
721
+
722
+ Returns:
723
+ Result containing list of orders or error
724
+
725
+ Rate limit weight: 5
726
+ """
727
+ # Constants for validation
728
+ MIN_LIMIT = 1
729
+ MAX_LIMIT = 1000
730
+ MAX_TIMESTAMP = 8640000000000000
731
+
732
+ # Validate options if provided
733
+ if options:
734
+ # Validate limit parameter
735
+ if "limit" in options:
736
+ limit = options["limit"]
737
+ if not isinstance(limit, int) or limit < MIN_LIMIT or limit > MAX_LIMIT:
738
+ msg = f"Invalid limit '{limit}'. Must be an integer between {MIN_LIMIT} and {MAX_LIMIT}"
739
+ raise ValueError(msg)
740
+
741
+ # Validate end timestamp
742
+ if "end" in options:
743
+ end = options["end"]
744
+ if not isinstance(end, int) or end > MAX_TIMESTAMP:
745
+ msg = f"Invalid end timestamp '{end}'. Must be <= {MAX_TIMESTAMP}"
746
+ raise ValueError(msg)
747
+
748
+ # Validate start/end relationship
749
+ if "start" in options and "end" in options and options["start"] > options["end"]:
750
+ msg = f"Start timestamp ({options['start']}) cannot be greater than end timestamp ({options['end']})"
751
+ raise ValueError(msg)
752
+
753
+ effective_model, effective_schema = self._get_effective_model("orders", model, schema)
754
+ options = default(options, {})
755
+ options["market"] = market
756
+ postfix = create_postfix(options)
757
+
758
+ # Get raw data first
759
+ raw_result = self.http.request("GET", f"/orders{postfix}", weight=5)
760
+
761
+ # Convert using the shared method
762
+ return self._convert_raw_result(raw_result, "orders", effective_model, effective_schema)
763
+
764
+ def cancel_orders(
765
+ self,
766
+ operator_id: int,
767
+ *,
768
+ market: str | None = None,
769
+ model: type[T] | Any | None = None,
770
+ schema: dict | None = None,
771
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
772
+ """Cancel all orders for a market or the entire account.
773
+
774
+ Args:
775
+ operator_id: Your identifier for the trader or bot within your account
776
+ market: Optional market symbol. If not specified, all open orders are canceled
777
+ model: Optional Pydantic model to validate response
778
+
779
+ Returns:
780
+ Result containing array of cancellation results or error
781
+
782
+ Example Response:
783
+ [
784
+ {
785
+ "orderId": "1be6d0df-d5dc-4b53-a250-3376f3b393e6",
786
+ "operatorId": 543462
787
+ }
788
+ ]
789
+ """
790
+ effective_model, effective_schema = self._get_effective_model("orders", model, schema)
791
+
792
+ # Build query parameters
793
+ params: dict[str, Any] = {"operatorId": operator_id}
794
+ if market is not None:
795
+ params["market"] = market
796
+
797
+ postfix = create_postfix(params)
798
+ return self.http.request("DELETE", f"/orders{postfix}", weight=1)
799
+
800
+ def orders_open(
801
+ self,
802
+ options: AnyDict | None = None,
803
+ *,
804
+ model: type[T] | Any | None = None,
805
+ schema: dict | None = None,
806
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
807
+ """Get all open orders.
808
+
809
+ Args:
810
+ options: Optional query parameters. Supports:
811
+ - market (str): Filter by specific market (e.g., 'BTC-EUR')
812
+ - base (str): Filter by base asset (e.g., 'BTC')
813
+ model: Optional Pydantic model to validate response
814
+
815
+ Returns:
816
+ Result containing open orders data or error
817
+
818
+ Rate limit: 25 points (without market), 1 point (with market)
819
+ """
820
+ effective_model, effective_schema = self._get_effective_model("orders", model, schema)
821
+ postfix = create_postfix(options)
822
+
823
+ # Rate limit is 1 point with market parameter, 25 points without
824
+ weight = 1 if options and "market" in options else 25
825
+
826
+ # Get raw data first
827
+ raw_result = self.http.request("GET", f"/ordersOpen{postfix}", weight=weight)
828
+
829
+ # Convert using the shared method
830
+ return self._convert_raw_result(raw_result, "orders", effective_model, effective_schema)
831
+
832
+ def fees(
833
+ self,
834
+ options: AnyDict | None = None,
835
+ *,
836
+ model: type[T] | Any | None = None,
837
+ schema: dict | None = None,
838
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
839
+ """Get market-specific trading fees.
840
+
841
+ Returns your current trading fees from the Category of the specified market
842
+ based on the trading volume of your account. This is different from the
843
+ account() method which returns general account information with fees wrapped
844
+ in a "fees" object plus capabilities.
845
+
846
+ Endpoint: GET /v2/account/fees
847
+ Rate limit weight: 1
848
+
849
+ Args:
850
+ options: Optional query parameters:
851
+ - market (str): Market symbol (e.g., 'BTC-EUR'). If not specified,
852
+ returns fees for your current tier in Category B.
853
+ - quote (str): Quote currency ('EUR' or 'USDC'). If not specified,
854
+ returns fees for your current tier in Category B.
855
+ model: Optional Pydantic model to validate response
856
+
857
+ Returns:
858
+ Result containing market fee information directly (no wrapper, includes tier):
859
+ {
860
+ "tier": "0",
861
+ "volume": "10000.00",
862
+ "taker": "0.0025",
863
+ "maker": "0.0015"
864
+ }
865
+
866
+ Note:
867
+ This differs from account() which returns:
868
+ {
869
+ "fees": {"tier": "0", "volume": "...", "maker": "...", "taker": "..."},
870
+ "capabilities": [...]
871
+ }
872
+ """
873
+ # Validate quote parameter if provided
874
+ if options and "quote" in options:
875
+ quote = options["quote"]
876
+ valid_quotes = ["EUR", "USDC"]
877
+ if quote not in valid_quotes:
878
+ msg = f"Invalid quote currency '{quote}'. Must be one of: {valid_quotes}"
879
+ raise ValueError(msg)
880
+
881
+ effective_model, effective_schema = self._get_effective_model("fees", model, schema)
882
+ postfix = create_postfix(options)
883
+
884
+ # Get raw data first
885
+ raw_result = self.http.request("GET", f"/account/fees{postfix}", weight=1)
886
+
887
+ # Convert using the shared method
888
+ return self._convert_raw_result(raw_result, "fees", effective_model, effective_schema)
889
+
890
+ def deposit(
891
+ self,
892
+ symbol: str,
893
+ *,
894
+ model: type[T] | Any | None = None,
895
+ schema: dict | None = None,
896
+ ) -> Result[T, BitvavoError | httpx.HTTPError | TypeError]:
897
+ """Get deposit data for making deposits.
898
+
899
+ Returns wallet or bank account information required to deposit digital or fiat assets.
900
+
901
+ Endpoint: GET /v2/deposit
902
+ Rate limit weight: 1
903
+
904
+ Args:
905
+ symbol: The asset symbol you want to deposit (e.g., 'BTC', 'EUR')
906
+ model: Optional Pydantic model to validate response
907
+ schema: Optional schema for DataFrame conversion
908
+
909
+ Returns:
910
+ Result containing deposit information:
911
+ - For digital assets: {"address": "string", "paymentid": "string"}
912
+ - For fiat: {"iban": "string", "bic": "string", "description": "string"}
913
+ """
914
+
915
+ effective_model, effective_schema = self._get_effective_model("deposit", model, schema)
916
+
917
+ if effective_model in _DATAFRAME_LIBRARY_MAP:
918
+ msg = "DataFrame model is not supported due to the shape of data"
919
+ return Failure(TypeError(msg))
920
+
921
+ params = {"symbol": symbol}
922
+ postfix = create_postfix(params)
923
+
924
+ # Get raw data first
925
+ raw_result = self.http.request(
926
+ "GET",
927
+ f"/deposit{postfix}",
928
+ weight=1,
929
+ )
930
+
931
+ # Convert using the shared method
932
+ return self._convert_raw_result(raw_result, "deposit", effective_model, effective_schema)
933
+
934
+ def deposit_history(
935
+ self,
936
+ options: AnyDict | None = None,
937
+ *,
938
+ model: type[T] | Any | None = None,
939
+ schema: dict | None = None,
940
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
941
+ """Get deposit history.
942
+
943
+ Ensures every deposit dict includes a non-empty "address" key (fallback to txId or "unknown").
944
+
945
+ Args:
946
+ options: Optional query parameters
947
+ model: Optional Pydantic model to validate response
948
+
949
+ Returns:
950
+ Result containing deposits data or error
951
+ """
952
+ effective_model, effective_schema = self._get_effective_model("deposit_history", model, schema)
953
+ postfix = create_postfix(options)
954
+
955
+ # Get raw data first
956
+ raw_result = self.http.request("GET", f"/depositHistory{postfix}", weight=5)
957
+
958
+ # Convert using the shared method
959
+ return self._convert_raw_result(raw_result, "deposit_history", effective_model, effective_schema)
960
+
961
+ def withdrawals(
962
+ self,
963
+ options: AnyDict | None = None,
964
+ *,
965
+ model: type[T] | Any | None = None,
966
+ schema: dict | None = None,
967
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
968
+ """Get withdrawal history.
969
+
970
+ Args:
971
+ options: Optional query parameters
972
+ model: Optional Pydantic model to validate response
973
+
974
+ Returns:
975
+ Result containing withdrawals data or error
976
+ """
977
+ effective_model, effective_schema = self._get_effective_model("withdrawals", model, schema)
978
+ postfix = create_postfix(options)
979
+
980
+ # Get raw data first
981
+ raw_result = self.http.request("GET", f"/withdrawalHistory{postfix}", weight=1)
982
+
983
+ # Convert using the shared method
984
+ return self._convert_raw_result(raw_result, "withdrawals", effective_model, effective_schema)
985
+
986
+ def _convert_transaction_items(
987
+ self,
988
+ items_data: list[dict],
989
+ effective_model: type[T] | Any | None,
990
+ effective_schema: dict | None,
991
+ ) -> Result[Any, BitvavoError]:
992
+ """Convert transaction items to the desired model format."""
993
+ if effective_model in _DATAFRAME_LIBRARY_MAP and isinstance(effective_model, ModelPreference):
994
+ # Convert items to DataFrame using the specific preference
995
+ return _create_dataframe_from_data(
996
+ items_data, effective_model, items_key=None, empty_schema=effective_schema
997
+ )
998
+
999
+ if effective_model is Any or effective_model is None:
1000
+ # Raw data - return items list directly
1001
+ return Success(items_data)
1002
+
1003
+ # Handle Pydantic or other model types
1004
+ try:
1005
+ if hasattr(effective_model, "model_validate"):
1006
+ # Pydantic model - validate items list
1007
+ parsed_items = effective_model.model_validate(items_data) # type: ignore[misc]
1008
+ elif effective_schema is None:
1009
+ # Simple constructor call
1010
+ parsed_items = effective_model(items_data) # type: ignore[misc]
1011
+ else:
1012
+ # Other models with schema
1013
+ parsed_items = effective_model(items_data, schema=effective_schema) # type: ignore[misc]
1014
+
1015
+ return Success(parsed_items)
1016
+ except (ValueError, TypeError, AttributeError) as exc:
1017
+ # If conversion fails, return a structured error
1018
+ error = BitvavoError(
1019
+ http_status=500,
1020
+ error_code=-1,
1021
+ reason="Model conversion failed",
1022
+ message=str(exc),
1023
+ raw=items_data if isinstance(items_data, dict) else {"raw": items_data},
1024
+ )
1025
+ return Failure(error)
1026
+
1027
+ def transaction_history(
1028
+ self,
1029
+ options: AnyDict | None = None,
1030
+ *,
1031
+ model: type[T] | Any | None = None,
1032
+ schema: dict | None = None,
1033
+ ) -> Result[tuple[Any, dict[str, Any]], BitvavoError | httpx.HTTPError]:
1034
+ """Get account transaction history.
1035
+
1036
+ Returns all past transactions for your account with pagination support.
1037
+
1038
+ Endpoint: GET /v2/account/history
1039
+ Rate limit weight: 1
1040
+
1041
+ Args:
1042
+ options: Optional query parameters:
1043
+ - fromDate (int): Unix timestamp in ms to start from (>=0)
1044
+ - toDate (int): Unix timestamp in ms to end at (<=8640000000000000)
1045
+ - page (int): Page number for pagination (>=1)
1046
+ - maxItems (int): Max number of items per page (1-100, default: 100)
1047
+ - type (str): Transaction type filter:
1048
+ 'sell', 'buy', 'staking', 'fixed_staking', 'deposit', 'withdrawal',
1049
+ 'affiliate', 'distribution', 'internal_transfer', 'withdrawal_cancelled',
1050
+ 'rebate', 'loan', 'external_transferred_funds', 'manually_assigned_bitvavo'
1051
+ model: Optional Pydantic model to validate response
1052
+ schema: Optional schema for DataFrame conversion
1053
+
1054
+ Returns:
1055
+ Result containing transaction history as tuple of (items_converted, metadata_dict):
1056
+ - For DataFrames: Returns tuple of (DataFrame of items, metadata dict)
1057
+ - For Pydantic models: Returns tuple of (Pydantic model of items, metadata dict)
1058
+ - For raw responses: Returns tuple of (list of items, metadata dict)
1059
+
1060
+ Each transaction contains:
1061
+ - transactionId: Unique transaction identifier
1062
+ - executedAt: Execution timestamp (ISO format)
1063
+ - type: Transaction type
1064
+ - priceCurrency/priceAmount: Transaction price info (optional for staking)
1065
+ - sentCurrency/sentAmount: Sent amounts (optional for staking)
1066
+ - receivedCurrency/receivedAmount: Received amounts
1067
+ - feesCurrency/feesAmount: Fee information (optional for staking)
1068
+ - address: Transaction address (nullable)
1069
+ """
1070
+ postfix = create_postfix(options)
1071
+
1072
+ # Get raw data first
1073
+ raw_result = self.http.request("GET", f"/account/history{postfix}", weight=1)
1074
+
1075
+ # Always split the response into items and metadata for all model types
1076
+ match raw_result:
1077
+ case Success(raw_data):
1078
+ # Validate response structure
1079
+ if not isinstance(raw_data, dict) or "items" not in raw_data:
1080
+ error = BitvavoError(
1081
+ http_status=500,
1082
+ error_code=-1,
1083
+ reason="Response parsing failed",
1084
+ message="Expected response to have 'items' key for transaction history",
1085
+ raw=raw_data if isinstance(raw_data, dict) else {"raw": raw_data},
1086
+ )
1087
+ return Failure(error)
1088
+
1089
+ # Extract items and metadata separately
1090
+ items_data = raw_data["items"]
1091
+ metadata = {k: v for k, v in raw_data.items() if k != "items"}
1092
+
1093
+ effective_model, effective_schema = self._get_effective_model("transaction_history", model, schema)
1094
+ # Convert items using helper method
1095
+ items_result = self._convert_transaction_items(items_data, effective_model, effective_schema)
1096
+ match items_result:
1097
+ case Success(converted_items):
1098
+ return Success((converted_items, metadata))
1099
+ case Failure(error):
1100
+ return Failure(error)
1101
+ case _:
1102
+ # This case should never be reached, but satisfies type checker
1103
+ msg = "Unexpected result type from _convert_transaction_items"
1104
+ raise RuntimeError(msg)
1105
+
1106
+ case Failure(error):
1107
+ return Failure(error)
1108
+ case _:
1109
+ # This case should never be reached, but satisfies type checker
1110
+ msg = "Unexpected result type from HTTP request"
1111
+ raise RuntimeError(msg)
1112
+
1113
+ def trade_history(
1114
+ self,
1115
+ market: str,
1116
+ options: AnyDict | None = None,
1117
+ *,
1118
+ model: type[T] | Any | None = None,
1119
+ schema: dict | None = None,
1120
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
1121
+ """Get trade history for your account.
1122
+
1123
+ Returns the specified number of past trades for your account, excluding Price Guarantee.
1124
+ The returned trades are sorted by their timestamp in descending order, from latest to earliest.
1125
+
1126
+ Endpoint: GET /v2/trades
1127
+ Rate limit weight: 5
1128
+
1129
+ Args:
1130
+ market: The market for which to return the past trades (e.g., 'BTC-EUR')
1131
+ options: Optional query parameters:
1132
+ - limit (int): Max number of trades (1-1000, default: 500)
1133
+ - start (int): Unix timestamp in ms to start from
1134
+ - end (int): Unix timestamp in ms to end at (max: 8640000000000000)
1135
+ - tradeIdFrom (str): Trade ID to start from
1136
+ - tradeIdTo (str): Trade ID to end at
1137
+ - tradeId (str, deprecated): Interpreted as tradeIdTo
1138
+ model: Optional Pydantic model to validate response
1139
+ schema: Optional schema for DataFrame conversion
1140
+
1141
+ Returns:
1142
+ Result containing list of trade objects with fields like:
1143
+ - id: Trade identifier
1144
+ - orderId: Bitvavo order ID
1145
+ - clientOrderId: Your order ID
1146
+ - timestamp: Unix timestamp in ms
1147
+ - market: Market symbol
1148
+ - side: 'buy' or 'sell'
1149
+ - amount: Base currency amount
1150
+ - price: Price per unit
1151
+ - taker: Whether you were the taker
1152
+ - fee: Fee paid (negative for rebates)
1153
+ - feeCurrency: Currency of the fee
1154
+ - settled: Whether fee was deducted
1155
+
1156
+ Note:
1157
+ This is a private endpoint that returns YOUR trades, different from the public
1158
+ trades endpoint which returns public market trades.
1159
+ """
1160
+ # Constants for validation
1161
+ MIN_LIMIT = 1
1162
+ MAX_LIMIT = 1000
1163
+ MAX_TIMESTAMP = 8640000000000000
1164
+
1165
+ # Validate options if provided
1166
+ if options:
1167
+ # Validate limit parameter
1168
+ if "limit" in options:
1169
+ limit = options["limit"]
1170
+ if not isinstance(limit, int) or limit < MIN_LIMIT or limit > MAX_LIMIT:
1171
+ msg = f"Invalid limit '{limit}'. Must be an integer between {MIN_LIMIT} and {MAX_LIMIT}"
1172
+ raise ValueError(msg)
1173
+
1174
+ # Validate end timestamp
1175
+ if "end" in options:
1176
+ end = options["end"]
1177
+ if not isinstance(end, int) or end > MAX_TIMESTAMP:
1178
+ msg = f"Invalid end timestamp '{end}'. Must be <= {MAX_TIMESTAMP}"
1179
+ raise ValueError(msg)
1180
+
1181
+ # Validate start/end relationship
1182
+ if "start" in options and "end" in options and options["start"] > options["end"]:
1183
+ msg = f"Start timestamp ({options['start']}) cannot be greater than end timestamp ({options['end']})"
1184
+ raise ValueError(msg)
1185
+
1186
+ # Handle deprecated tradeId parameter
1187
+ if "tradeId" in options and "tradeIdTo" not in options:
1188
+ # Move deprecated tradeId to tradeIdTo as per documentation
1189
+ options = options.copy() # Don't mutate the original
1190
+ options["tradeIdTo"] = options.pop("tradeId")
1191
+
1192
+ effective_model, effective_schema = self._get_effective_model("trade_history", model, schema)
1193
+
1194
+ # Add market to options
1195
+ query_options = default(options, {})
1196
+ query_options["market"] = market
1197
+
1198
+ postfix = create_postfix(query_options)
1199
+
1200
+ # Get raw data first
1201
+ raw_result = self.http.request("GET", f"/trades{postfix}", weight=5)
1202
+
1203
+ # Convert using the shared method
1204
+ return self._convert_raw_result(raw_result, "trade_history", effective_model, effective_schema)
1205
+
1206
+ def withdraw(
1207
+ self,
1208
+ symbol: str,
1209
+ amount: str,
1210
+ address: str,
1211
+ options: AnyDict | None = None,
1212
+ *,
1213
+ model: type[T] | Any | None = None,
1214
+ schema: dict | None = None,
1215
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
1216
+ """Withdraw assets.
1217
+
1218
+ Args:
1219
+ symbol: Asset symbol
1220
+ amount: Amount to withdraw
1221
+ address: Withdrawal address
1222
+ options: Optional parameters
1223
+ model: Optional Pydantic model to validate response
1224
+
1225
+ Returns:
1226
+ Result containing withdrawal result or error
1227
+ """
1228
+ effective_model, effective_schema = self._get_effective_model("withdraw", model, schema)
1229
+ body = {"symbol": symbol, "amount": amount, "address": address}
1230
+ if options:
1231
+ body.update(options)
1232
+ return self.http.request("POST", "/withdrawal", body=body, weight=1)