bitvavo-api-upgraded 4.1.0__py3-none-any.whl → 4.1.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.
@@ -0,0 +1,1090 @@
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
+ import polars as pl
10
+ from returns.result import Failure, Result, Success
11
+
12
+ from bitvavo_client.adapters.returns_adapter import BitvavoError
13
+ from bitvavo_client.core import private_models
14
+ from bitvavo_client.core.model_preferences import ModelPreference
15
+ from bitvavo_client.endpoints.common import create_postfix, default
16
+ from bitvavo_client.schemas.private_schemas import DEFAULT_SCHEMAS
17
+
18
+ if TYPE_CHECKING: # pragma: no cover
19
+ from bitvavo_client.core.types import AnyDict
20
+ from bitvavo_client.transport.http import HTTPClient
21
+
22
+ T = TypeVar("T")
23
+
24
+
25
+ def _extract_dataframe_data(data: Any, *, items_key: str | None = None) -> list[dict] | dict:
26
+ """Extract the meaningful data for DataFrame creation from API responses.
27
+
28
+ Args:
29
+ data: Raw API response data
30
+ items_key: Key to extract from nested response (e.g., 'items' for paginated responses)
31
+
32
+ Returns:
33
+ List of dicts or single dict suitable for DataFrame creation
34
+
35
+ Raises:
36
+ ValueError: If data format is unexpected
37
+ """
38
+ if items_key and isinstance(data, dict) and items_key in data:
39
+ # Extract nested items (e.g., transaction_history['items'])
40
+ items = data[items_key]
41
+ if not isinstance(items, list):
42
+ msg = f"Expected {items_key} to be a list, got {type(items)}"
43
+ raise ValueError(msg)
44
+ return items
45
+ if isinstance(data, list):
46
+ # Direct list response (e.g., balance, trades)
47
+ return data
48
+ if isinstance(data, dict):
49
+ # Single dict response - wrap in list for DataFrame
50
+ return [data]
51
+
52
+ msg = f"Unexpected data type for DataFrame creation: {type(data)}"
53
+ raise ValueError(msg)
54
+
55
+
56
+ def _create_dataframe_from_data(
57
+ data: Any, *, items_key: str | None = None, empty_schema: dict[str, Any] | None = None
58
+ ) -> Result[pl.DataFrame, BitvavoError]:
59
+ """Create a DataFrame from API response data.
60
+
61
+ Args:
62
+ data: Raw API response data
63
+ items_key: Key to extract from nested response (optional)
64
+ empty_schema: Schema to use for empty DataFrames and type casting
65
+
66
+ Returns:
67
+ Result containing DataFrame or error
68
+ """
69
+ try:
70
+ df_data = _extract_dataframe_data(data, items_key=items_key)
71
+
72
+ if df_data:
73
+ # Create DataFrame with flexible schema first
74
+ df = pl.DataFrame(df_data, strict=False)
75
+
76
+ # Apply schema casting if provided
77
+ if empty_schema:
78
+ # Cast columns that exist in both DataFrame and schema
79
+ for col, expected_dtype in empty_schema.items():
80
+ if col in df.columns:
81
+ with contextlib.suppress(pl.exceptions.PolarsError, ValueError):
82
+ df = df.with_columns(pl.col(col).cast(expected_dtype))
83
+
84
+ return Success(df) # type: ignore[return-value]
85
+
86
+ # Create empty DataFrame with provided schema or minimal default
87
+ if empty_schema is None:
88
+ empty_schema = {"id": pl.String} # Minimal default schema
89
+ df = pl.DataFrame([], schema=empty_schema)
90
+ return Success(df) # type: ignore[return-value]
91
+
92
+ except (ValueError, TypeError, pl.exceptions.PolarsError) as exc:
93
+ error = BitvavoError(
94
+ http_status=500,
95
+ error_code=-1,
96
+ reason="DataFrame creation failed",
97
+ message=f"Failed to create DataFrame from API response: {exc}",
98
+ raw={"data_type": type(data).__name__, "data_sample": str(data)[:200]},
99
+ )
100
+ return Failure(error)
101
+
102
+
103
+ class PrivateAPI:
104
+ """Handles all private Bitvavo API endpoints requiring authentication."""
105
+
106
+ def __init__(
107
+ self,
108
+ http_client: HTTPClient,
109
+ *,
110
+ preferred_model: ModelPreference | str | None = None,
111
+ default_schema: dict | None = None,
112
+ ) -> None:
113
+ """Initialize private API handler.
114
+
115
+ Args:
116
+ http_client: HTTP client for making requests
117
+ preferred_model: Preferred model format for responses
118
+ default_schema: Default schema for DataFrame conversion
119
+ """
120
+ self.http: HTTPClient = http_client
121
+ self.preferred_model = ModelPreference(preferred_model) if preferred_model else None
122
+ self.default_schema = default_schema
123
+
124
+ def _get_effective_model(
125
+ self,
126
+ endpoint_type: str,
127
+ model: type[T] | Any | None,
128
+ schema: dict | None,
129
+ ) -> tuple[type[T] | Any | None, dict | None]:
130
+ """Get the effective model and schema to use for a request.
131
+
132
+ Args:
133
+ endpoint_type: Type of endpoint (e.g., 'account', 'balance', 'orders')
134
+ model: Model explicitly passed to method (overrides preference)
135
+ schema: Schema explicitly passed to method
136
+
137
+ Returns:
138
+ Tuple of (effective_model, effective_schema)
139
+
140
+ Raises:
141
+ ValueError: If endpoint_type is not recognized
142
+ """
143
+ # If model is explicitly provided, use it
144
+ if model is not None:
145
+ return model, schema
146
+
147
+ # If no preferred model is set, return None (raw response)
148
+ if self.preferred_model is None:
149
+ return None, schema
150
+
151
+ # Apply preference based on enum value
152
+ if self.preferred_model == ModelPreference.RAW:
153
+ return Any, schema
154
+
155
+ if self.preferred_model == ModelPreference.DATAFRAME:
156
+ # Validate endpoint type
157
+ if endpoint_type not in list(DEFAULT_SCHEMAS.keys()):
158
+ msg = f"Invalid endpoint_type '{endpoint_type}'. Valid types: {sorted(DEFAULT_SCHEMAS)}"
159
+ raise ValueError(msg)
160
+ # Use the provided schema, fallback to instance default, then to endpoint-specific default
161
+ effective_schema = schema or self.default_schema or DEFAULT_SCHEMAS.get(endpoint_type)
162
+ # Convert to dict if it's a Mapping but not already a dict
163
+ if effective_schema is not None and not isinstance(effective_schema, dict):
164
+ effective_schema = dict(effective_schema)
165
+ return pl.DataFrame, effective_schema
166
+
167
+ if self.preferred_model == ModelPreference.PYDANTIC:
168
+ # Map endpoint types to appropriate Pydantic models
169
+ endpoint_model_map = {
170
+ "account": private_models.Account,
171
+ "balance": private_models.Balances,
172
+ "orders": private_models.Orders,
173
+ "order": private_models.Order,
174
+ "trade_history": private_models.Trades,
175
+ "transaction_history": private_models.TransactionHistory,
176
+ "fees": private_models.Fees,
177
+ "deposit_history": private_models.DepositHistories,
178
+ "deposit": private_models.Deposit,
179
+ "withdrawals": private_models.Withdrawals,
180
+ "withdraw": private_models.WithdrawResponse,
181
+ "cancel_order": private_models.CancelOrderResponse,
182
+ }
183
+ if endpoint_type not in endpoint_model_map:
184
+ msg = f"No Pydantic model defined for endpoint_type '{endpoint_type}'. Add it to endpoint_model_map."
185
+ raise ValueError(msg)
186
+ return endpoint_model_map[endpoint_type], schema
187
+
188
+ # Default case (AUTO or unknown)
189
+ return None, schema
190
+
191
+ def _convert_raw_result(
192
+ self,
193
+ raw_result: Result[Any, BitvavoError | httpx.HTTPError],
194
+ endpoint_type: str,
195
+ model: type[T] | Any | None,
196
+ schema: dict | None,
197
+ *,
198
+ items_key: str | None = None,
199
+ ) -> Result[Any, BitvavoError | httpx.HTTPError]:
200
+ """Convert raw API result to the desired model format.
201
+
202
+ Args:
203
+ raw_result: Raw result from HTTP client
204
+ endpoint_type: Type of endpoint (e.g., 'account', 'balance', 'orders')
205
+ model: Model explicitly passed to method (overrides preference)
206
+ schema: Schema explicitly passed to method
207
+ items_key: Key to extract from nested response for DataFrames
208
+
209
+ Returns:
210
+ Result with converted data or original error
211
+ """
212
+ # If the raw result is an error, return it as-is
213
+ if isinstance(raw_result, Failure):
214
+ return raw_result
215
+
216
+ # Get the effective model and schema to use
217
+ effective_model, effective_schema = self._get_effective_model(endpoint_type, model, schema)
218
+
219
+ # If no conversion needed (raw data requested), return as-is
220
+ if effective_model is Any or effective_model is None:
221
+ return raw_result
222
+
223
+ # Extract the raw data
224
+ raw_data = raw_result.unwrap()
225
+
226
+ # Handle DataFrames specially
227
+ if effective_model is pl.DataFrame:
228
+ return _create_dataframe_from_data(raw_data, items_key=items_key, empty_schema=effective_schema)
229
+
230
+ # Handle other model types using the same logic as PublicAPI
231
+ try:
232
+ # Handle different model types
233
+ if hasattr(effective_model, "model_validate"):
234
+ # Pydantic model
235
+ parsed = effective_model.model_validate(raw_data) # type: ignore[misc]
236
+ elif effective_schema is None:
237
+ # Simple constructor call - this handles dict and other simple types
238
+ parsed = effective_model(raw_data) # type: ignore[misc]
239
+ else:
240
+ # Other models with schema
241
+ parsed = effective_model(raw_data, schema=effective_schema) # type: ignore[misc]
242
+
243
+ return Success(parsed)
244
+ except (ValueError, TypeError, AttributeError) as exc:
245
+ # If conversion fails, return a structured error
246
+ error = BitvavoError(
247
+ http_status=500,
248
+ error_code=-1,
249
+ reason="Model conversion failed",
250
+ message=str(exc),
251
+ raw=raw_data if isinstance(raw_data, dict) else {"raw": raw_data},
252
+ )
253
+ return Failure(error)
254
+
255
+ def account(
256
+ self,
257
+ *,
258
+ model: type[T] | Any | None = None,
259
+ schema: dict | None = None,
260
+ ) -> Result[T, BitvavoError | httpx.HTTPError | TypeError]:
261
+ """Get account information including fees and capabilities.
262
+
263
+ Endpoint: GET /v2/account
264
+ Rate limit weight: 1
265
+
266
+ Args:
267
+ model: Optional Pydantic model to validate response
268
+
269
+ Returns:
270
+ Result containing account information including fees and capabilities:
271
+ {
272
+ "fees": {
273
+ "tier": 0,
274
+ "volume": "0.00",
275
+ "maker": "0.0015",
276
+ "taker": "0.0025"
277
+ },
278
+ "capabilities": [
279
+ "buy", "sell", "depositCrypto", "depositFiat",
280
+ "withdrawCrypto", "withdrawFiat"
281
+ ]
282
+ }
283
+ """
284
+ # Check if DataFrame is requested - not supported for this endpoint
285
+ effective_model, effective_schema = self._get_effective_model("account", model, schema)
286
+ if effective_model is pl.DataFrame:
287
+ msg = "DataFrame model is not supported due to the shape of data"
288
+ return Failure(TypeError(msg))
289
+
290
+ # Get raw data from API
291
+ raw_result = self.http.request("GET", "/account", weight=1)
292
+ # Convert to desired format
293
+ return self._convert_raw_result(raw_result, "account", model, schema)
294
+
295
+ def balance(
296
+ self,
297
+ options: AnyDict | None = None,
298
+ *,
299
+ model: type[T] | Any | None = None,
300
+ schema: dict | None = None,
301
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
302
+ """Get account balance.
303
+
304
+ Args:
305
+ options: Optional query parameters
306
+ model: Optional Pydantic model to validate response
307
+
308
+ Returns:
309
+ Result containing balance information or error
310
+ """
311
+ postfix = create_postfix(options)
312
+
313
+ # Get raw data from API
314
+ raw_result = self.http.request("GET", f"/balance{postfix}", weight=5)
315
+ # Convert to desired format
316
+ return self._convert_raw_result(raw_result, "balance", model, schema)
317
+
318
+ def place_order(
319
+ self,
320
+ market: str,
321
+ side: str,
322
+ order_type: str,
323
+ operator_id: int,
324
+ body: AnyDict,
325
+ *,
326
+ response_required: bool = True,
327
+ model: type[T] | Any | None = None,
328
+ schema: dict | None = None,
329
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
330
+ """Place a new order.
331
+
332
+ Args:
333
+ market: Market symbol
334
+ side: Order side ('buy' or 'sell')
335
+ order_type: Order type ('market', 'limit', etc.)
336
+ operator_id: Your identifier for the trader or bot within your account
337
+ body: Order parameters (amount, price, etc.)
338
+ response_required: Whether to return full order details (True) or minimal response (False)
339
+
340
+ Returns:
341
+ Order placement result
342
+ """
343
+ effective_model, effective_schema = self._get_effective_model("order", model, schema)
344
+ payload = {
345
+ "market": market,
346
+ "side": side,
347
+ "orderType": order_type,
348
+ "operatorId": operator_id,
349
+ "responseRequired": response_required,
350
+ **body,
351
+ }
352
+ return self.http.request("POST", "/order", body=payload, weight=1)
353
+
354
+ def get_order(
355
+ self,
356
+ market: str,
357
+ order_id: str,
358
+ *,
359
+ model: type[T] | Any | None = None,
360
+ schema: dict | None = None,
361
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
362
+ """Get order by ID.
363
+
364
+ Args:
365
+ market: Market symbol
366
+ order_id: Order ID
367
+ model: Optional Pydantic model to validate response
368
+
369
+ Returns:
370
+ Result containing order information or error
371
+ """
372
+ # Get raw data from API
373
+ raw_result = self.http.request(
374
+ "GET",
375
+ f"/{market}/order",
376
+ body={"orderId": order_id},
377
+ weight=1,
378
+ )
379
+ # Convert to desired format
380
+ return self._convert_raw_result(raw_result, "order", model, schema)
381
+
382
+ def update_order(
383
+ self,
384
+ market: str,
385
+ operator_id: int,
386
+ *,
387
+ order_id: str | None = None,
388
+ client_order_id: str | None = None,
389
+ amount: str | None = None,
390
+ amount_quote: str | None = None,
391
+ amount_remaining: str | None = None,
392
+ price: str | None = None,
393
+ trigger_amount: str | None = None,
394
+ time_in_force: str | None = None,
395
+ self_trade_prevention: str | None = None,
396
+ post_only: bool | None = None,
397
+ response_required: bool | None = None,
398
+ model: type[T] | Any | None = None,
399
+ schema: dict | None = None,
400
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
401
+ """Update an existing limit or trigger order.
402
+
403
+ Args:
404
+ market: Market symbol (e.g., 'BTC-EUR')
405
+ operator_id: Your identifier for the trader or bot within your account
406
+ order_id: Bitvavo identifier of the order to update (required if client_order_id not provided)
407
+ client_order_id: Your identifier of the order to update (required if order_id not provided)
408
+ amount: Amount of base currency to update
409
+ amount_quote: Amount of quote currency (market orders only)
410
+ amount_remaining: Remaining amount of base currency to update
411
+ price: Price for limit orders
412
+ trigger_amount: Trigger price for stop orders
413
+ time_in_force: Time in force ('GTC', 'IOC', 'FOK')
414
+ self_trade_prevention: Self-trade prevention
415
+ ('decrementAndCancel', 'cancelOldest', 'cancelNewest', 'cancelBoth')
416
+ post_only: Whether order should only be maker
417
+ response_required: Whether to return full response or just status code
418
+ model: Optional Pydantic model to validate response
419
+ schema: Optional schema for DataFrame conversion
420
+
421
+ Returns:
422
+ Result containing updated order information or error
423
+
424
+ Note:
425
+ - You must set either order_id or client_order_id
426
+ - If both are set, client_order_id takes precedence
427
+ - Updates are faster than canceling and creating new orders
428
+ - Only works with limit or trigger orders
429
+ """
430
+ if not order_id and not client_order_id:
431
+ error = BitvavoError(
432
+ http_status=400,
433
+ error_code=203,
434
+ reason="Missing or incompatible parameters in your request.",
435
+ message="Either order_id or client_order_id must be provided",
436
+ raw={"provided": {"order_id": order_id, "client_order_id": client_order_id}},
437
+ )
438
+ return Failure(error)
439
+
440
+ effective_model, effective_schema = self._get_effective_model("order", model, schema)
441
+ payload = self._build_update_order_payload(
442
+ market,
443
+ operator_id,
444
+ order_id,
445
+ client_order_id,
446
+ amount,
447
+ amount_quote,
448
+ amount_remaining,
449
+ price,
450
+ trigger_amount,
451
+ time_in_force,
452
+ self_trade_prevention,
453
+ post_only=post_only,
454
+ response_required=response_required,
455
+ )
456
+
457
+ return self.http.request("PUT", "/order", body=payload, weight=1)
458
+
459
+ def _build_update_order_payload(
460
+ self,
461
+ market: str,
462
+ operator_id: int,
463
+ order_id: str | None,
464
+ client_order_id: str | None,
465
+ amount: str | None,
466
+ amount_quote: str | None,
467
+ amount_remaining: str | None,
468
+ price: str | None,
469
+ trigger_amount: str | None,
470
+ time_in_force: str | None,
471
+ self_trade_prevention: str | None,
472
+ *,
473
+ post_only: bool | None,
474
+ response_required: bool | None,
475
+ ) -> dict[str, Any]:
476
+ """Build the payload for update order request."""
477
+ payload = {
478
+ "market": market,
479
+ "operatorId": operator_id,
480
+ }
481
+
482
+ # Add order identifier - clientOrderId takes precedence if both provided
483
+ if client_order_id:
484
+ payload["clientOrderId"] = client_order_id
485
+ elif order_id:
486
+ payload["orderId"] = order_id
487
+
488
+ # Add optional update parameters
489
+ payload.update(
490
+ {
491
+ key: value
492
+ for key, value in {
493
+ "amount": amount,
494
+ "amountQuote": amount_quote,
495
+ "amountRemaining": amount_remaining,
496
+ "price": price,
497
+ "triggerAmount": trigger_amount,
498
+ "timeInForce": time_in_force,
499
+ "selfTradePrevention": self_trade_prevention,
500
+ "postOnly": post_only,
501
+ "responseRequired": response_required,
502
+ }.items()
503
+ if value is not None
504
+ }
505
+ )
506
+
507
+ return payload
508
+
509
+ def cancel_order(
510
+ self,
511
+ market: str,
512
+ operator_id: int,
513
+ *,
514
+ order_id: str | None = None,
515
+ client_order_id: str | None = None,
516
+ model: type[T] | Any | None = None,
517
+ schema: dict | None = None,
518
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
519
+ """Cancel an order.
520
+
521
+ Args:
522
+ market: Market symbol
523
+ operator_id: Your identifier for the trader or bot within your account
524
+ order_id: Bitvavo identifier of the order (required if client_order_id not provided)
525
+ client_order_id: Your identifier of the order (required if order_id not provided)
526
+ model: Optional Pydantic model to validate response
527
+
528
+ Returns:
529
+ Result containing cancellation result or error
530
+
531
+ Note:
532
+ You must set either order_id or client_order_id. If you set both,
533
+ client_order_id takes precedence as per Bitvavo documentation.
534
+ """
535
+ if not order_id and not client_order_id:
536
+ # Create a validation error using httpx.HTTPError as a fallback
537
+ error = httpx.RequestError("Either order_id or client_order_id must be provided")
538
+ return Failure(error)
539
+
540
+ # Build query parameters
541
+ params = {
542
+ "market": market,
543
+ "operatorId": operator_id,
544
+ }
545
+
546
+ if client_order_id:
547
+ params["clientOrderId"] = client_order_id
548
+ elif order_id:
549
+ params["orderId"] = order_id
550
+
551
+ # Create query string
552
+ postfix = create_postfix(params)
553
+
554
+ effective_model, effective_schema = self._get_effective_model("cancel_order", model, schema)
555
+ return self.http.request(
556
+ "DELETE",
557
+ f"/order{postfix}",
558
+ weight=1,
559
+ )
560
+
561
+ def get_orders(
562
+ self,
563
+ market: str,
564
+ options: AnyDict | None = None,
565
+ *,
566
+ model: type[T] | Any | None = None,
567
+ schema: dict | None = None,
568
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
569
+ """Get orders for a market.
570
+
571
+ Args:
572
+ market: Market symbol (e.g., 'BTC-EUR')
573
+ options: Optional query parameters:
574
+ - limit (int): Max number of orders (1-1000, default: 500)
575
+ - start (int): Unix timestamp in ms to start from
576
+ - end (int): Unix timestamp in ms to end at (max: 8640000000000000)
577
+ - orderIdFrom (str): UUID to start from
578
+ - orderIdTo (str): UUID to end at
579
+ model: Optional Pydantic model to validate response
580
+ schema: Optional schema for DataFrame conversion
581
+
582
+ Returns:
583
+ Result containing list of orders or error
584
+
585
+ Rate limit weight: 5
586
+ """
587
+ # Constants for validation
588
+ MIN_LIMIT = 1
589
+ MAX_LIMIT = 1000
590
+ MAX_TIMESTAMP = 8640000000000000
591
+
592
+ # Validate options if provided
593
+ if options:
594
+ # Validate limit parameter
595
+ if "limit" in options:
596
+ limit = options["limit"]
597
+ if not isinstance(limit, int) or limit < MIN_LIMIT or limit > MAX_LIMIT:
598
+ msg = f"Invalid limit '{limit}'. Must be an integer between {MIN_LIMIT} and {MAX_LIMIT}"
599
+ raise ValueError(msg)
600
+
601
+ # Validate end timestamp
602
+ if "end" in options:
603
+ end = options["end"]
604
+ if not isinstance(end, int) or end > MAX_TIMESTAMP:
605
+ msg = f"Invalid end timestamp '{end}'. Must be <= {MAX_TIMESTAMP}"
606
+ raise ValueError(msg)
607
+
608
+ # Validate start/end relationship
609
+ if "start" in options and "end" in options and options["start"] > options["end"]:
610
+ msg = f"Start timestamp ({options['start']}) cannot be greater than end timestamp ({options['end']})"
611
+ raise ValueError(msg)
612
+
613
+ effective_model, effective_schema = self._get_effective_model("orders", model, schema)
614
+ options = default(options, {})
615
+ options["market"] = market
616
+ postfix = create_postfix(options)
617
+
618
+ # Get raw data first
619
+ raw_result = self.http.request("GET", f"/orders{postfix}", weight=5)
620
+
621
+ # Convert using the shared method
622
+ return self._convert_raw_result(raw_result, "orders", effective_model, effective_schema)
623
+
624
+ def cancel_orders(
625
+ self,
626
+ operator_id: int,
627
+ *,
628
+ market: str | None = None,
629
+ model: type[T] | Any | None = None,
630
+ schema: dict | None = None,
631
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
632
+ """Cancel all orders for a market or the entire account.
633
+
634
+ Args:
635
+ operator_id: Your identifier for the trader or bot within your account
636
+ market: Optional market symbol. If not specified, all open orders are canceled
637
+ model: Optional Pydantic model to validate response
638
+
639
+ Returns:
640
+ Result containing array of cancellation results or error
641
+
642
+ Example Response:
643
+ [
644
+ {
645
+ "orderId": "1be6d0df-d5dc-4b53-a250-3376f3b393e6",
646
+ "operatorId": 543462
647
+ }
648
+ ]
649
+ """
650
+ effective_model, effective_schema = self._get_effective_model("orders", model, schema)
651
+
652
+ # Build query parameters
653
+ params: dict[str, Any] = {"operatorId": operator_id}
654
+ if market is not None:
655
+ params["market"] = market
656
+
657
+ postfix = create_postfix(params)
658
+ return self.http.request("DELETE", f"/orders{postfix}", weight=1)
659
+
660
+ def orders_open(
661
+ self,
662
+ options: AnyDict | None = None,
663
+ *,
664
+ model: type[T] | Any | None = None,
665
+ schema: dict | None = None,
666
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
667
+ """Get all open orders.
668
+
669
+ Args:
670
+ options: Optional query parameters. Supports:
671
+ - market (str): Filter by specific market (e.g., 'BTC-EUR')
672
+ - base (str): Filter by base asset (e.g., 'BTC')
673
+ model: Optional Pydantic model to validate response
674
+
675
+ Returns:
676
+ Result containing open orders data or error
677
+
678
+ Rate limit: 25 points (without market), 1 point (with market)
679
+ """
680
+ effective_model, effective_schema = self._get_effective_model("orders", model, schema)
681
+ postfix = create_postfix(options)
682
+
683
+ # Rate limit is 1 point with market parameter, 25 points without
684
+ weight = 1 if options and "market" in options else 25
685
+
686
+ # Get raw data first
687
+ raw_result = self.http.request("GET", f"/ordersOpen{postfix}", weight=weight)
688
+
689
+ # Convert using the shared method
690
+ return self._convert_raw_result(raw_result, "orders", effective_model, effective_schema)
691
+
692
+ def fees(
693
+ self,
694
+ options: AnyDict | None = None,
695
+ *,
696
+ model: type[T] | Any | None = None,
697
+ schema: dict | None = None,
698
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
699
+ """Get market-specific trading fees.
700
+
701
+ Returns your current trading fees from the Category of the specified market
702
+ based on the trading volume of your account. This is different from the
703
+ account() method which returns general account information with fees wrapped
704
+ in a "fees" object plus capabilities.
705
+
706
+ Endpoint: GET /v2/account/fees
707
+ Rate limit weight: 1
708
+
709
+ Args:
710
+ options: Optional query parameters:
711
+ - market (str): Market symbol (e.g., 'BTC-EUR'). If not specified,
712
+ returns fees for your current tier in Category B.
713
+ - quote (str): Quote currency ('EUR' or 'USDC'). If not specified,
714
+ returns fees for your current tier in Category B.
715
+ model: Optional Pydantic model to validate response
716
+
717
+ Returns:
718
+ Result containing market fee information directly (no wrapper, includes tier):
719
+ {
720
+ "tier": "0",
721
+ "volume": "10000.00",
722
+ "taker": "0.0025",
723
+ "maker": "0.0015"
724
+ }
725
+
726
+ Note:
727
+ This differs from account() which returns:
728
+ {
729
+ "fees": {"tier": "0", "volume": "...", "maker": "...", "taker": "..."},
730
+ "capabilities": [...]
731
+ }
732
+ """
733
+ # Validate quote parameter if provided
734
+ if options and "quote" in options:
735
+ quote = options["quote"]
736
+ valid_quotes = ["EUR", "USDC"]
737
+ if quote not in valid_quotes:
738
+ msg = f"Invalid quote currency '{quote}'. Must be one of: {valid_quotes}"
739
+ raise ValueError(msg)
740
+
741
+ effective_model, effective_schema = self._get_effective_model("fees", model, schema)
742
+ postfix = create_postfix(options)
743
+
744
+ # Get raw data first
745
+ raw_result = self.http.request("GET", f"/account/fees{postfix}", weight=1)
746
+
747
+ # Convert using the shared method
748
+ return self._convert_raw_result(raw_result, "fees", effective_model, effective_schema)
749
+
750
+ def deposit(
751
+ self,
752
+ symbol: str,
753
+ *,
754
+ model: type[T] | Any | None = None,
755
+ schema: dict | None = None,
756
+ ) -> Result[T, BitvavoError | httpx.HTTPError | TypeError]:
757
+ """Get deposit data for making deposits.
758
+
759
+ Returns wallet or bank account information required to deposit digital or fiat assets.
760
+
761
+ Endpoint: GET /v2/deposit
762
+ Rate limit weight: 1
763
+
764
+ Args:
765
+ symbol: The asset symbol you want to deposit (e.g., 'BTC', 'EUR')
766
+ model: Optional Pydantic model to validate response
767
+ schema: Optional schema for DataFrame conversion
768
+
769
+ Returns:
770
+ Result containing deposit information:
771
+ - For digital assets: {"address": "string", "paymentid": "string"}
772
+ - For fiat: {"iban": "string", "bic": "string", "description": "string"}
773
+ """
774
+
775
+ effective_model, effective_schema = self._get_effective_model("deposit", model, schema)
776
+
777
+ if effective_model is pl.DataFrame:
778
+ msg = "DataFrame model is not supported due to the shape of data"
779
+ return Failure(TypeError(msg))
780
+
781
+ params = {"symbol": symbol}
782
+ postfix = create_postfix(params)
783
+
784
+ # Get raw data first
785
+ raw_result = self.http.request(
786
+ "GET",
787
+ f"/deposit{postfix}",
788
+ weight=1,
789
+ )
790
+
791
+ # Convert using the shared method
792
+ return self._convert_raw_result(raw_result, "deposit", effective_model, effective_schema)
793
+
794
+ def deposit_history(
795
+ self,
796
+ options: AnyDict | None = None,
797
+ *,
798
+ model: type[T] | Any | None = None,
799
+ schema: dict | None = None,
800
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
801
+ """Get deposit history.
802
+
803
+ Ensures every deposit dict includes a non-empty "address" key (fallback to txId or "unknown").
804
+
805
+ Args:
806
+ options: Optional query parameters
807
+ model: Optional Pydantic model to validate response
808
+
809
+ Returns:
810
+ Result containing deposits data or error
811
+ """
812
+ effective_model, effective_schema = self._get_effective_model("deposit_history", model, schema)
813
+ postfix = create_postfix(options)
814
+
815
+ # Get raw data first
816
+ raw_result = self.http.request("GET", f"/depositHistory{postfix}", weight=5)
817
+
818
+ # Convert using the shared method
819
+ return self._convert_raw_result(raw_result, "deposit_history", effective_model, effective_schema)
820
+
821
+ def withdrawals(
822
+ self,
823
+ options: AnyDict | None = None,
824
+ *,
825
+ model: type[T] | Any | None = None,
826
+ schema: dict | None = None,
827
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
828
+ """Get withdrawal history.
829
+
830
+ Args:
831
+ options: Optional query parameters
832
+ model: Optional Pydantic model to validate response
833
+
834
+ Returns:
835
+ Result containing withdrawals data or error
836
+ """
837
+ effective_model, effective_schema = self._get_effective_model("withdrawals", model, schema)
838
+ postfix = create_postfix(options)
839
+
840
+ # Get raw data first
841
+ raw_result = self.http.request("GET", f"/withdrawalHistory{postfix}", weight=1)
842
+
843
+ # Convert using the shared method
844
+ return self._convert_raw_result(raw_result, "withdrawals", effective_model, effective_schema)
845
+
846
+ def _convert_transaction_items(
847
+ self,
848
+ items_data: list[dict],
849
+ effective_model: type[T] | Any | None,
850
+ effective_schema: dict | None,
851
+ ) -> Result[Any, BitvavoError]:
852
+ """Convert transaction items to the desired model format."""
853
+ if effective_model is pl.DataFrame:
854
+ # Convert items to DataFrame
855
+ return _create_dataframe_from_data(items_data, items_key=None, empty_schema=effective_schema)
856
+
857
+ if effective_model is Any or effective_model is None:
858
+ # Raw data - return items list directly
859
+ return Success(items_data)
860
+
861
+ # Handle Pydantic or other model types
862
+ try:
863
+ if hasattr(effective_model, "model_validate"):
864
+ # Pydantic model - validate items list
865
+ parsed_items = effective_model.model_validate(items_data) # type: ignore[misc]
866
+ elif effective_schema is None:
867
+ # Simple constructor call
868
+ parsed_items = effective_model(items_data) # type: ignore[misc]
869
+ else:
870
+ # Other models with schema
871
+ parsed_items = effective_model(items_data, schema=effective_schema) # type: ignore[misc]
872
+
873
+ return Success(parsed_items)
874
+ except (ValueError, TypeError, AttributeError) as exc:
875
+ # If conversion fails, return a structured error
876
+ error = BitvavoError(
877
+ http_status=500,
878
+ error_code=-1,
879
+ reason="Model conversion failed",
880
+ message=str(exc),
881
+ raw=items_data if isinstance(items_data, dict) else {"raw": items_data},
882
+ )
883
+ return Failure(error)
884
+
885
+ def transaction_history(
886
+ self,
887
+ options: AnyDict | None = None,
888
+ *,
889
+ model: type[T] | Any | None = None,
890
+ schema: dict | None = None,
891
+ ) -> Result[tuple[Any, dict[str, Any]], BitvavoError | httpx.HTTPError]:
892
+ """Get account transaction history.
893
+
894
+ Returns all past transactions for your account with pagination support.
895
+
896
+ Endpoint: GET /v2/account/history
897
+ Rate limit weight: 1
898
+
899
+ Args:
900
+ options: Optional query parameters:
901
+ - fromDate (int): Unix timestamp in ms to start from (>=0)
902
+ - toDate (int): Unix timestamp in ms to end at (<=8640000000000000)
903
+ - page (int): Page number for pagination (>=1)
904
+ - maxItems (int): Max number of items per page (1-100, default: 100)
905
+ - type (str): Transaction type filter:
906
+ 'sell', 'buy', 'staking', 'fixed_staking', 'deposit', 'withdrawal',
907
+ 'affiliate', 'distribution', 'internal_transfer', 'withdrawal_cancelled',
908
+ 'rebate', 'loan', 'external_transferred_funds', 'manually_assigned_bitvavo'
909
+ model: Optional Pydantic model to validate response
910
+ schema: Optional schema for DataFrame conversion
911
+
912
+ Returns:
913
+ Result containing transaction history as tuple of (items_converted, metadata_dict):
914
+ - For DataFrames: Returns tuple of (DataFrame of items, metadata dict)
915
+ - For Pydantic models: Returns tuple of (Pydantic model of items, metadata dict)
916
+ - For raw responses: Returns tuple of (list of items, metadata dict)
917
+
918
+ Each transaction contains:
919
+ - transactionId: Unique transaction identifier
920
+ - executedAt: Execution timestamp (ISO format)
921
+ - type: Transaction type
922
+ - priceCurrency/priceAmount: Transaction price info (optional for staking)
923
+ - sentCurrency/sentAmount: Sent amounts (optional for staking)
924
+ - receivedCurrency/receivedAmount: Received amounts
925
+ - feesCurrency/feesAmount: Fee information (optional for staking)
926
+ - address: Transaction address (nullable)
927
+ """
928
+ postfix = create_postfix(options)
929
+
930
+ # Get raw data first
931
+ raw_result = self.http.request("GET", f"/account/history{postfix}", weight=1)
932
+
933
+ # Always split the response into items and metadata for all model types
934
+ match raw_result:
935
+ case Success(raw_data):
936
+ # Validate response structure
937
+ if not isinstance(raw_data, dict) or "items" not in raw_data:
938
+ error = BitvavoError(
939
+ http_status=500,
940
+ error_code=-1,
941
+ reason="Response parsing failed",
942
+ message="Expected response to have 'items' key for transaction history",
943
+ raw=raw_data if isinstance(raw_data, dict) else {"raw": raw_data},
944
+ )
945
+ return Failure(error)
946
+
947
+ # Extract items and metadata separately
948
+ items_data = raw_data["items"]
949
+ metadata = {k: v for k, v in raw_data.items() if k != "items"}
950
+
951
+ effective_model, effective_schema = self._get_effective_model("transaction_history", model, schema)
952
+ # Convert items using helper method
953
+ items_result = self._convert_transaction_items(items_data, effective_model, effective_schema)
954
+ match items_result:
955
+ case Success(converted_items):
956
+ return Success((converted_items, metadata))
957
+ case Failure(error):
958
+ return Failure(error)
959
+ case _:
960
+ # This case should never be reached, but satisfies type checker
961
+ msg = "Unexpected result type from _convert_transaction_items"
962
+ raise RuntimeError(msg)
963
+
964
+ case Failure(error):
965
+ return Failure(error)
966
+ case _:
967
+ # This case should never be reached, but satisfies type checker
968
+ msg = "Unexpected result type from HTTP request"
969
+ raise RuntimeError(msg)
970
+
971
+ def trade_history(
972
+ self,
973
+ market: str,
974
+ options: AnyDict | None = None,
975
+ *,
976
+ model: type[T] | Any | None = None,
977
+ schema: dict | None = None,
978
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
979
+ """Get trade history for your account.
980
+
981
+ Returns the specified number of past trades for your account, excluding Price Guarantee.
982
+ The returned trades are sorted by their timestamp in descending order, from latest to earliest.
983
+
984
+ Endpoint: GET /v2/trades
985
+ Rate limit weight: 5
986
+
987
+ Args:
988
+ market: The market for which to return the past trades (e.g., 'BTC-EUR')
989
+ options: Optional query parameters:
990
+ - limit (int): Max number of trades (1-1000, default: 500)
991
+ - start (int): Unix timestamp in ms to start from
992
+ - end (int): Unix timestamp in ms to end at (max: 8640000000000000)
993
+ - tradeIdFrom (str): Trade ID to start from
994
+ - tradeIdTo (str): Trade ID to end at
995
+ - tradeId (str, deprecated): Interpreted as tradeIdTo
996
+ model: Optional Pydantic model to validate response
997
+ schema: Optional schema for DataFrame conversion
998
+
999
+ Returns:
1000
+ Result containing list of trade objects with fields like:
1001
+ - id: Trade identifier
1002
+ - orderId: Bitvavo order ID
1003
+ - clientOrderId: Your order ID
1004
+ - timestamp: Unix timestamp in ms
1005
+ - market: Market symbol
1006
+ - side: 'buy' or 'sell'
1007
+ - amount: Base currency amount
1008
+ - price: Price per unit
1009
+ - taker: Whether you were the taker
1010
+ - fee: Fee paid (negative for rebates)
1011
+ - feeCurrency: Currency of the fee
1012
+ - settled: Whether fee was deducted
1013
+
1014
+ Note:
1015
+ This is a private endpoint that returns YOUR trades, different from the public
1016
+ trades endpoint which returns public market trades.
1017
+ """
1018
+ # Constants for validation
1019
+ MIN_LIMIT = 1
1020
+ MAX_LIMIT = 1000
1021
+ MAX_TIMESTAMP = 8640000000000000
1022
+
1023
+ # Validate options if provided
1024
+ if options:
1025
+ # Validate limit parameter
1026
+ if "limit" in options:
1027
+ limit = options["limit"]
1028
+ if not isinstance(limit, int) or limit < MIN_LIMIT or limit > MAX_LIMIT:
1029
+ msg = f"Invalid limit '{limit}'. Must be an integer between {MIN_LIMIT} and {MAX_LIMIT}"
1030
+ raise ValueError(msg)
1031
+
1032
+ # Validate end timestamp
1033
+ if "end" in options:
1034
+ end = options["end"]
1035
+ if not isinstance(end, int) or end > MAX_TIMESTAMP:
1036
+ msg = f"Invalid end timestamp '{end}'. Must be <= {MAX_TIMESTAMP}"
1037
+ raise ValueError(msg)
1038
+
1039
+ # Validate start/end relationship
1040
+ if "start" in options and "end" in options and options["start"] > options["end"]:
1041
+ msg = f"Start timestamp ({options['start']}) cannot be greater than end timestamp ({options['end']})"
1042
+ raise ValueError(msg)
1043
+
1044
+ # Handle deprecated tradeId parameter
1045
+ if "tradeId" in options and "tradeIdTo" not in options:
1046
+ # Move deprecated tradeId to tradeIdTo as per documentation
1047
+ options = options.copy() # Don't mutate the original
1048
+ options["tradeIdTo"] = options.pop("tradeId")
1049
+
1050
+ effective_model, effective_schema = self._get_effective_model("trade_history", model, schema)
1051
+
1052
+ # Add market to options
1053
+ query_options = default(options, {})
1054
+ query_options["market"] = market
1055
+
1056
+ postfix = create_postfix(query_options)
1057
+
1058
+ # Get raw data first
1059
+ raw_result = self.http.request("GET", f"/trades{postfix}", weight=5)
1060
+
1061
+ # Convert using the shared method
1062
+ return self._convert_raw_result(raw_result, "trade_history", effective_model, effective_schema)
1063
+
1064
+ def withdraw(
1065
+ self,
1066
+ symbol: str,
1067
+ amount: str,
1068
+ address: str,
1069
+ options: AnyDict | None = None,
1070
+ *,
1071
+ model: type[T] | Any | None = None,
1072
+ schema: dict | None = None,
1073
+ ) -> Result[T, BitvavoError | httpx.HTTPError]:
1074
+ """Withdraw assets.
1075
+
1076
+ Args:
1077
+ symbol: Asset symbol
1078
+ amount: Amount to withdraw
1079
+ address: Withdrawal address
1080
+ options: Optional parameters
1081
+ model: Optional Pydantic model to validate response
1082
+
1083
+ Returns:
1084
+ Result containing withdrawal result or error
1085
+ """
1086
+ effective_model, effective_schema = self._get_effective_model("withdraw", model, schema)
1087
+ body = {"symbol": symbol, "amount": amount, "address": address}
1088
+ if options:
1089
+ body.update(options)
1090
+ return self.http.request("POST", "/withdrawal", body=body, weight=1)