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,886 @@
1
+ """Pydantic models for validating API responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator
9
+
10
+
11
+ class PlaceOrderRequest(BaseModel):
12
+ """Request model for creating a new order."""
13
+
14
+ model_config = ConfigDict(
15
+ frozen=True,
16
+ extra="forbid",
17
+ str_strip_whitespace=True,
18
+ validate_assignment=True,
19
+ )
20
+
21
+ # Required fields
22
+ market: str = Field(..., description="Market symbol (e.g., 'BTC-EUR')")
23
+ side: Literal["buy", "sell"] = Field(..., description="Order side")
24
+ order_type: str = Field(..., alias="orderType", description="Order type")
25
+ operator_id: int = Field(..., alias="operatorId", description="Operator ID", ge=1)
26
+
27
+ # Optional fields
28
+ client_order_id: str | None = Field(None, alias="clientOrderId", description="Client-provided order ID")
29
+ amount: str | None = Field(None, description="Base currency amount as string")
30
+ amount_quote: str | None = Field(None, alias="amountQuote", description="Quote currency amount as string")
31
+ price: str | None = Field(None, description="Price as string (for limit orders)")
32
+ trigger_amount: str | None = Field(None, alias="triggerAmount", description="Trigger amount as string")
33
+ trigger_type: str | None = Field(None, alias="triggerType", description="Trigger type")
34
+ trigger_reference: str | None = Field(None, alias="triggerReference", description="Trigger reference")
35
+ time_in_force: str | None = Field(None, alias="timeInForce", description="Time in force")
36
+ post_only: bool | None = Field(None, alias="postOnly", description="Post-only flag")
37
+ self_trade_prevention: str | None = Field(None, alias="selfTradePrevention", description="Self-trade prevention")
38
+ disable_market_protection: bool | None = Field(
39
+ None, alias="disableMarketProtection", description="Disable market protection (deprecated, must be false)"
40
+ )
41
+ response_required: bool | None = Field(None, alias="responseRequired", description="Response required flag")
42
+
43
+ @field_validator("side")
44
+ @classmethod
45
+ def _validate_side(cls, v: str) -> Literal["buy", "sell"]:
46
+ if v not in ("buy", "sell"):
47
+ msg = f"side must be 'buy' or 'sell', got '{v}'"
48
+ raise ValueError(msg)
49
+ return v # type: ignore[return-value]
50
+
51
+ @field_validator("order_type")
52
+ @classmethod
53
+ def _validate_order_type(cls, v: str) -> str:
54
+ valid_types = {"market", "limit", "stopLoss", "stopLossLimit", "takeProfit", "takeProfitLimit"}
55
+ if v not in valid_types:
56
+ msg = f"order_type must be one of {valid_types}, got '{v}'"
57
+ raise ValueError(msg)
58
+ return v
59
+
60
+ @field_validator("amount", "amount_quote", "price", "trigger_amount")
61
+ @classmethod
62
+ def _validate_numeric_strings(cls, v: str | None) -> str | None:
63
+ if v is None:
64
+ return v
65
+ try:
66
+ d = Decimal(v)
67
+ except Exception as exc:
68
+ msg = "must be a valid numeric string"
69
+ raise ValueError(msg) from exc
70
+ if d <= 0:
71
+ msg = "must be positive"
72
+ raise ValueError(msg)
73
+ return v
74
+
75
+ @field_validator("disable_market_protection")
76
+ @classmethod
77
+ def _validate_market_protection(cls, v: bool | None) -> bool | None: # noqa: FBT001 (bool as arg)
78
+ if v is True:
79
+ msg = "disable_market_protection must be false (market protection cannot be disabled)"
80
+ raise ValueError(msg)
81
+ return v
82
+
83
+
84
+ class UpdateOrderRequest(BaseModel):
85
+ """Request model for updating an existing order."""
86
+
87
+ model_config = ConfigDict(
88
+ frozen=True,
89
+ extra="forbid",
90
+ str_strip_whitespace=True,
91
+ validate_assignment=True,
92
+ )
93
+
94
+ # Required fields
95
+ market: str = Field(..., description="Market symbol (e.g., 'BTC-EUR')")
96
+ operator_id: int = Field(..., alias="operatorId", description="Your identifier for the trader or bot", ge=1)
97
+
98
+ # Either orderId or clientOrderId must be provided
99
+ order_id: str | None = Field(None, alias="orderId", description="Bitvavo identifier of the order to update")
100
+ client_order_id: str | None = Field(
101
+ None, alias="clientOrderId", description="Your identifier of the order to update"
102
+ )
103
+
104
+ # Optional update fields
105
+ amount: str | None = Field(None, description="Base currency amount as string")
106
+ amount_quote: str | None = Field(None, alias="amountQuote", description="Quote currency amount as string")
107
+ amount_remaining: str | None = Field(None, alias="amountRemaining", description="Remaining amount of base currency")
108
+ price: str | None = Field(None, description="Price as string")
109
+ trigger_amount: str | None = Field(None, alias="triggerAmount", description="Trigger amount as string")
110
+ time_in_force: Literal["GTC", "IOC", "FOK"] | None = Field(None, alias="timeInForce", description="Time in force")
111
+ self_trade_prevention: Literal["decrementAndCancel", "cancelOldest", "cancelNewest", "cancelBoth"] | None = Field(
112
+ None, alias="selfTradePrevention", description="Self-trade prevention"
113
+ )
114
+ post_only: bool | None = Field(None, alias="postOnly", description="Post-only flag")
115
+ response_required: bool | None = Field(None, alias="responseRequired", description="Response required flag")
116
+
117
+ @model_validator(mode="after")
118
+ def _validate_order_identifier(self) -> UpdateOrderRequest:
119
+ """Ensure either order_id or client_order_id is provided."""
120
+ if not self.order_id and not self.client_order_id:
121
+ msg = "Either order_id or client_order_id must be provided"
122
+ raise ValueError(msg)
123
+ return self
124
+
125
+ @field_validator("amount", "amount_quote", "amount_remaining", "price", "trigger_amount")
126
+ @classmethod
127
+ def _validate_numeric_strings(cls, v: str | None) -> str | None:
128
+ if v is None:
129
+ return v
130
+ try:
131
+ d = Decimal(v)
132
+ except Exception as exc:
133
+ msg = "must be a valid numeric string"
134
+ raise ValueError(msg) from exc
135
+ if d <= 0:
136
+ msg = "must be positive"
137
+ raise ValueError(msg)
138
+ return v
139
+
140
+
141
+ class Fees(BaseModel):
142
+ """Account fee information as returned by Bitvavo."""
143
+
144
+ model_config = ConfigDict(
145
+ frozen=True,
146
+ extra="forbid",
147
+ str_strip_whitespace=True,
148
+ validate_assignment=True,
149
+ )
150
+
151
+ tier: int = Field(..., description="Fee tier", ge=0)
152
+ volume: str = Field(..., description="30d trading volume as string")
153
+ maker: str = Field(..., description="Maker fee as string (e.g. '0.0015')")
154
+ taker: str = Field(..., description="Taker fee as string (e.g. '0.0025')")
155
+
156
+ @field_validator("volume", "maker", "taker")
157
+ @classmethod
158
+ def _numeric_str(cls, v: str) -> str:
159
+ try:
160
+ d = Decimal(v)
161
+ except Exception as exc:
162
+ msg = "must be a numeric string"
163
+ raise ValueError(msg) from exc
164
+ if d < 0:
165
+ msg = "must be non-negative"
166
+ raise ValueError(msg)
167
+ return v
168
+
169
+ def maker_decimal(self) -> Decimal:
170
+ return Decimal(self.maker)
171
+
172
+ def taker_decimal(self) -> Decimal:
173
+ return Decimal(self.taker)
174
+
175
+ def volume_decimal(self) -> Decimal:
176
+ return Decimal(self.volume)
177
+
178
+
179
+ class Account(BaseModel):
180
+ """Bitvavo account model (mirrors /account response)."""
181
+
182
+ model_config = ConfigDict(
183
+ frozen=True,
184
+ extra="forbid",
185
+ str_strip_whitespace=True,
186
+ validate_assignment=True,
187
+ )
188
+
189
+ fees: Fees = Field(..., description="Account fee information")
190
+ capabilities: list[str] = Field(..., description="Enabled account capabilities", min_length=0)
191
+
192
+ @field_validator("capabilities")
193
+ @classmethod
194
+ def _validate_capabilities(cls, v: list[str]) -> list[str]:
195
+ if not isinstance(v, list):
196
+ msg = "capabilities must be a list of strings"
197
+ raise TypeError(msg)
198
+ if not all(isinstance(x, str) and x.strip() for x in v):
199
+ msg = "capabilities entries must be non-empty strings"
200
+ raise ValueError(msg)
201
+ return [s.strip() for s in v]
202
+
203
+ def has_capability(self, capability: str) -> bool:
204
+ return capability in self.capabilities
205
+
206
+
207
+ class Balance(BaseModel):
208
+ """Asset balance as returned by GET /balance."""
209
+
210
+ model_config = ConfigDict(
211
+ frozen=True,
212
+ extra="forbid",
213
+ str_strip_whitespace=True,
214
+ validate_assignment=True,
215
+ )
216
+
217
+ symbol: str = Field(..., description="Asset symbol (e.g. 'BTC')")
218
+ available: str = Field(..., description="Available amount as string")
219
+ in_order: str = Field(..., alias="inOrder", description="Amount currently in open orders as string")
220
+
221
+ @field_validator("symbol")
222
+ @classmethod
223
+ def _validate_symbol(cls, v: str) -> str:
224
+ if not isinstance(v, str) or not v.strip():
225
+ msg = "symbol must be a non-empty string"
226
+ raise ValueError(msg)
227
+ return v.strip()
228
+
229
+ @field_validator("available", "in_order")
230
+ @classmethod
231
+ def _numeric_str(cls, v: str) -> str:
232
+ try:
233
+ d = Decimal(v)
234
+ except Exception as exc:
235
+ msg = "must be a numeric string"
236
+ raise ValueError(msg) from exc
237
+ if d < 0:
238
+ msg = "must be non-negative"
239
+ raise ValueError(msg)
240
+ return v
241
+
242
+ def available_decimal(self) -> Decimal:
243
+ return Decimal(self.available)
244
+
245
+ def in_order_decimal(self) -> Decimal:
246
+ return Decimal(self.in_order)
247
+
248
+ def total_decimal(self) -> Decimal:
249
+ return self.available_decimal() + self.in_order_decimal()
250
+
251
+
252
+ class Balances(RootModel[list[Balance]]):
253
+ """List model for GET /balance response."""
254
+
255
+ model_config = ConfigDict(
256
+ frozen=True,
257
+ str_strip_whitespace=True,
258
+ validate_assignment=True,
259
+ )
260
+
261
+ @field_validator("root")
262
+ @classmethod
263
+ def _validate_unique_symbols(cls, v: list[Balance]) -> list[Balance]:
264
+ seen: set[str] = set()
265
+ for b in v:
266
+ if b.symbol in seen:
267
+ msg = f"duplicate balance for symbol '{b.symbol}'"
268
+ raise ValueError(msg)
269
+ seen.add(b.symbol)
270
+ return v
271
+
272
+ def by_symbol(self, symbol: str) -> Balance | None:
273
+ s = symbol.strip()
274
+ for b in self.root:
275
+ if b.symbol == s:
276
+ return b
277
+ return None
278
+
279
+ def as_dict(self) -> dict[str, Balance]:
280
+ return {b.symbol: b for b in self.root}
281
+
282
+ def totals(self) -> dict[str, Decimal]:
283
+ return {b.symbol: b.total_decimal() for b in self.root}
284
+
285
+
286
+ class OrderFill(BaseModel):
287
+ """Fill details for an order."""
288
+
289
+ model_config = ConfigDict(
290
+ frozen=True,
291
+ extra="forbid",
292
+ str_strip_whitespace=True,
293
+ validate_assignment=True,
294
+ )
295
+
296
+ id: str = Field(..., description="Fill ID")
297
+ timestamp: int = Field(..., description="Fill timestamp in milliseconds")
298
+ amount: str = Field(..., description="Fill amount as string")
299
+ price: str = Field(..., description="Fill price as string")
300
+ taker: bool = Field(..., description="Whether this was a taker order")
301
+ fee: str = Field(..., description="Fee paid as string")
302
+ fee_currency: str = Field(..., alias="feeCurrency", description="Currency of the fee")
303
+ settled: bool = Field(..., description="Whether the fill is settled")
304
+
305
+ @field_validator("amount", "price", "fee")
306
+ @classmethod
307
+ def _numeric_str(cls, v: str) -> str:
308
+ try:
309
+ d = Decimal(v)
310
+ except Exception as exc:
311
+ msg = "must be a numeric string"
312
+ raise ValueError(msg) from exc
313
+ if d < 0:
314
+ msg = "must be non-negative"
315
+ raise ValueError(msg)
316
+ return v
317
+
318
+ def amount_decimal(self) -> Decimal:
319
+ return Decimal(self.amount)
320
+
321
+ def price_decimal(self) -> Decimal:
322
+ return Decimal(self.price)
323
+
324
+ def fee_decimal(self) -> Decimal:
325
+ return Decimal(self.fee)
326
+
327
+
328
+ class Order(BaseModel):
329
+ """Order details from various order endpoints."""
330
+
331
+ model_config = ConfigDict(
332
+ frozen=True,
333
+ extra="forbid",
334
+ str_strip_whitespace=True,
335
+ validate_assignment=True,
336
+ )
337
+
338
+ order_id: str = Field(..., alias="orderId", description="Order ID")
339
+ market: str = Field(..., description="Market symbol (e.g. 'BTC-EUR')")
340
+ created: int = Field(..., description="Creation timestamp in milliseconds")
341
+ updated: int = Field(..., description="Last update timestamp in milliseconds")
342
+ status: str = Field(..., description="Order status (new, filled, partiallyFilled, canceled)")
343
+ side: Literal["buy", "sell"] = Field(..., description="Order side")
344
+ order_type: str = Field(..., alias="orderType", description="Order type (limit, market, stopLoss, etc.)")
345
+
346
+ # Optional fields that may not be present in all order responses
347
+ client_order_id: str | None = Field(None, alias="clientOrderId", description="Client-provided order ID")
348
+ self_trade_prevention: str | None = Field(
349
+ None, alias="selfTradePrevention", description="Self trade prevention setting"
350
+ )
351
+ visible: bool | None = Field(None, description="Whether order is visible in order book")
352
+ on_hold: str | None = Field(None, alias="onHold", description="Amount on hold as string")
353
+ on_hold_currency: str | None = Field(None, alias="onHoldCurrency", description="Currency of the on hold amount")
354
+ fills: list[OrderFill] = Field(default_factory=list, description="Order fills")
355
+ fee_paid: str | None = Field(None, alias="feePaid", description="Total fee paid as string")
356
+ fee_currency: str | None = Field(None, alias="feeCurrency", description="Currency of the fee")
357
+ operator_id: int | None = Field(None, alias="operatorId", description="Operator ID")
358
+ price: str | None = Field(None, description="Order price as string (for limit orders)")
359
+ time_in_force: str | None = Field(None, alias="timeInForce", description="Time in force (GTC, IOC, FOK)")
360
+ post_only: bool | None = Field(None, alias="postOnly", description="Whether order is post-only")
361
+ amount: str | None = Field(None, description="Order amount as string")
362
+ amount_remaining: str | None = Field(None, alias="amountRemaining", description="Remaining amount as string")
363
+ filled_amount: str | None = Field(None, alias="filledAmount", description="Filled amount as string")
364
+ filled_amount_quote: str | None = Field(
365
+ None, alias="filledAmountQuote", description="Filled quote amount as string"
366
+ )
367
+ amount_quote: str | None = Field(
368
+ None, alias="amountQuote", description="Quote amount as string (for market orders)"
369
+ )
370
+ amount_quote_remaining: str | None = Field(
371
+ None, alias="amountQuoteRemaining", description="Remaining quote amount as string"
372
+ )
373
+ created_ns: int | None = Field(None, alias="createdNs", description="Creation timestamp in nanoseconds")
374
+ updated_ns: int | None = Field(None, alias="updatedNs", description="Last update timestamp in nanoseconds")
375
+ disable_market_protection: bool | None = Field(
376
+ None, alias="disableMarketProtection", description="Whether market protection is disabled"
377
+ )
378
+ trigger_price: str | None = Field(None, alias="triggerPrice", description="Calculated trigger price as string")
379
+ trigger_amount: str | None = Field(
380
+ None, alias="triggerAmount", description="User-specified trigger amount as string"
381
+ )
382
+ trigger_type: str | None = Field(None, alias="triggerType", description="Type of trigger (e.g., 'price')")
383
+ trigger_reference: str | None = Field(
384
+ None,
385
+ alias="triggerReference",
386
+ description="Price reference for triggering (lastTrade, bestBid, bestAsk, midPrice)",
387
+ )
388
+ restatement_reason: str | None = Field(
389
+ None, alias="restatementReason", description="Reason for order status change (e.g., cancellation reason)"
390
+ )
391
+
392
+ @field_validator("side")
393
+ @classmethod
394
+ def _validate_side(cls, v: str) -> Literal["buy", "sell"]:
395
+ if v not in ("buy", "sell"):
396
+ msg = f"side must be 'buy' or 'sell', got '{v}'"
397
+ raise ValueError(msg)
398
+ return v # type: ignore[return-value]
399
+
400
+ @field_validator(
401
+ "on_hold",
402
+ "fee_paid",
403
+ "price",
404
+ "amount",
405
+ "amount_remaining",
406
+ "filled_amount",
407
+ "filled_amount_quote",
408
+ "amount_quote",
409
+ "amount_quote_remaining",
410
+ "trigger_price",
411
+ "trigger_amount",
412
+ )
413
+ @classmethod
414
+ def _numeric_str_optional(cls, v: str | None) -> str | None:
415
+ if v is None:
416
+ return v
417
+ try:
418
+ d = Decimal(v)
419
+ except Exception as exc:
420
+ msg = "must be a numeric string"
421
+ raise ValueError(msg) from exc
422
+ if d < 0:
423
+ msg = "must be non-negative"
424
+ raise ValueError(msg)
425
+ return v
426
+
427
+ def price_decimal(self) -> Decimal | None:
428
+ return Decimal(self.price) if self.price else None
429
+
430
+ def amount_decimal(self) -> Decimal | None:
431
+ return Decimal(self.amount) if self.amount else None
432
+
433
+ def filled_amount_decimal(self) -> Decimal | None:
434
+ return Decimal(self.filled_amount) if self.filled_amount else None
435
+
436
+ def trigger_price_decimal(self) -> Decimal | None:
437
+ return Decimal(self.trigger_price) if self.trigger_price else None
438
+
439
+ def trigger_amount_decimal(self) -> Decimal | None:
440
+ return Decimal(self.trigger_amount) if self.trigger_amount else None
441
+
442
+
443
+ class Orders(RootModel[list[Order]]):
444
+ """List model for order responses."""
445
+
446
+ model_config = ConfigDict(
447
+ frozen=True,
448
+ str_strip_whitespace=True,
449
+ validate_assignment=True,
450
+ )
451
+
452
+ def by_id(self, order_id: str) -> Order | None:
453
+ for order in self.root:
454
+ if order.order_id == order_id:
455
+ return order
456
+ return None
457
+
458
+ def by_market(self, market: str) -> list[Order]:
459
+ return [order for order in self.root if order.market == market]
460
+
461
+ def open_orders(self) -> list[Order]:
462
+ return [order for order in self.root if order.status in ("new", "partiallyFilled")]
463
+
464
+
465
+ class CancelOrderResponse(BaseModel):
466
+ """Response model for order cancellation."""
467
+
468
+ model_config = ConfigDict(
469
+ frozen=True,
470
+ extra="forbid",
471
+ str_strip_whitespace=True,
472
+ validate_assignment=True,
473
+ )
474
+
475
+ order_id: str = Field(..., alias="orderId", description="Bitvavo identifier of the cancelled order")
476
+ client_order_id: str | None = Field(
477
+ None, alias="clientOrderId", description="Your identifier of the cancelled order"
478
+ )
479
+ operator_id: int = Field(..., alias="operatorId", description="Operator ID")
480
+
481
+
482
+ class Trade(BaseModel):
483
+ """Trade details from trade endpoints."""
484
+
485
+ model_config = ConfigDict(
486
+ frozen=True,
487
+ extra="forbid",
488
+ str_strip_whitespace=True,
489
+ validate_assignment=True,
490
+ )
491
+
492
+ id: str = Field(..., description="Trade ID")
493
+ timestamp: int = Field(..., description="Trade timestamp in milliseconds")
494
+ amount: str = Field(..., description="Trade amount as string")
495
+ price: str = Field(..., description="Trade price as string")
496
+ side: Literal["buy", "sell"] = Field(..., description="Trade side")
497
+
498
+ # Optional fields
499
+ market: str | None = Field(None, description="Market symbol (e.g. 'BTC-EUR')")
500
+ fee: str | None = Field(None, description="Fee paid as string")
501
+ fee_currency: str | None = Field(None, alias="feeCurrency", description="Currency of the fee")
502
+ settled: bool | None = Field(None, description="Whether the trade is settled")
503
+
504
+ @field_validator("side")
505
+ @classmethod
506
+ def _validate_side(cls, v: str) -> Literal["buy", "sell"]:
507
+ if v not in ("buy", "sell"):
508
+ msg = f"side must be 'buy' or 'sell', got '{v}'"
509
+ raise ValueError(msg)
510
+ return v # type: ignore[return-value]
511
+
512
+ @field_validator("amount", "price", "fee")
513
+ @classmethod
514
+ def _numeric_str_optional(cls, v: str | None) -> str | None:
515
+ if v is None:
516
+ return v
517
+ try:
518
+ d = Decimal(v)
519
+ except Exception as exc:
520
+ msg = "must be a numeric string"
521
+ raise ValueError(msg) from exc
522
+ if d < 0:
523
+ msg = "must be non-negative"
524
+ raise ValueError(msg)
525
+ return v
526
+
527
+ def amount_decimal(self) -> Decimal:
528
+ return Decimal(self.amount)
529
+
530
+ def price_decimal(self) -> Decimal:
531
+ return Decimal(self.price)
532
+
533
+ def fee_decimal(self) -> Decimal | None:
534
+ return Decimal(self.fee) if self.fee else None
535
+
536
+
537
+ class Trades(RootModel[list[Trade]]):
538
+ """List model for trade responses."""
539
+
540
+ model_config = ConfigDict(
541
+ frozen=True,
542
+ str_strip_whitespace=True,
543
+ validate_assignment=True,
544
+ )
545
+
546
+ def by_market(self, market: str) -> list[Trade]:
547
+ return [trade for trade in self.root if trade.market == market]
548
+
549
+ def by_side(self, side: Literal["buy", "sell"]) -> list[Trade]:
550
+ return [trade for trade in self.root if trade.side == side]
551
+
552
+
553
+ class DepositHistory(BaseModel):
554
+ """Deposit details from deposit history."""
555
+
556
+ model_config = ConfigDict(
557
+ frozen=True,
558
+ str_strip_whitespace=True,
559
+ validate_assignment=True,
560
+ )
561
+
562
+ timestamp: int = Field(..., description="Deposit timestamp in milliseconds")
563
+ symbol: str = Field(..., description="Asset symbol (e.g. 'EUR', 'BTC')")
564
+ amount: str = Field(..., description="Deposit amount as string")
565
+ fee: str = Field(..., description="Deposit fee as string")
566
+ status: str = Field(..., description="Deposit status (completed, pending, etc.)")
567
+ address: str | None = Field(None, description="Deposit address")
568
+
569
+ # Optional fields
570
+ payment_id: str | None = Field(None, alias="paymentId", description="Payment ID if required (for crypto deposits)")
571
+ tx_id: str | None = Field(None, alias="txId", description="Transaction ID (for crypto deposits)")
572
+
573
+ @field_validator("amount", "fee")
574
+ @classmethod
575
+ def _numeric_str(cls, v: str) -> str:
576
+ try:
577
+ d = Decimal(v)
578
+ except Exception as exc:
579
+ msg = "must be a numeric string"
580
+ raise ValueError(msg) from exc
581
+ if d < 0:
582
+ msg = "must be non-negative"
583
+ raise ValueError(msg)
584
+ return v
585
+
586
+ def amount_decimal(self) -> Decimal:
587
+ return Decimal(self.amount)
588
+
589
+ def fee_decimal(self) -> Decimal:
590
+ return Decimal(self.fee)
591
+
592
+ def get_address(self) -> str:
593
+ """Get deposit address, falling back to txId or 'unknown' if neither is available."""
594
+ return self.address or self.tx_id or "unknown"
595
+
596
+
597
+ class DepositHistories(RootModel[list[DepositHistory]]):
598
+ """List model for deposit history responses."""
599
+
600
+ model_config = ConfigDict(
601
+ frozen=True,
602
+ str_strip_whitespace=True,
603
+ validate_assignment=True,
604
+ )
605
+
606
+ def by_symbol(self, symbol: str) -> list[DepositHistory]:
607
+ return [deposit for deposit in self.root if deposit.symbol == symbol]
608
+
609
+ def by_status(self, status: str) -> list[DepositHistory]:
610
+ return [deposit for deposit in self.root if deposit.status == status]
611
+
612
+
613
+ class Withdrawal(BaseModel):
614
+ """Withdrawal details from withdrawal history."""
615
+
616
+ model_config = ConfigDict(
617
+ frozen=True,
618
+ extra="forbid",
619
+ str_strip_whitespace=True,
620
+ validate_assignment=True,
621
+ )
622
+
623
+ timestamp: int = Field(..., description="Withdrawal timestamp in milliseconds")
624
+ symbol: str = Field(..., description="Asset symbol (e.g. 'EUR', 'BTC')")
625
+ amount: str = Field(..., description="Withdrawal amount as string")
626
+ fee: str = Field(..., description="Withdrawal fee as string")
627
+ status: str = Field(..., description="Withdrawal status (completed, pending, etc.)")
628
+ address: str = Field(..., description="Withdrawal address")
629
+
630
+ # Optional fields
631
+ tx_id: str | None = Field(None, alias="txId", description="Transaction ID (for crypto withdrawals)")
632
+
633
+ @field_validator("amount", "fee")
634
+ @classmethod
635
+ def _numeric_str(cls, v: str) -> str:
636
+ try:
637
+ d = Decimal(v)
638
+ except Exception as exc:
639
+ msg = "must be a numeric string"
640
+ raise ValueError(msg) from exc
641
+ if d < 0:
642
+ msg = "must be non-negative"
643
+ raise ValueError(msg)
644
+ return v
645
+
646
+ def amount_decimal(self) -> Decimal:
647
+ return Decimal(self.amount)
648
+
649
+ def fee_decimal(self) -> Decimal:
650
+ return Decimal(self.fee)
651
+
652
+
653
+ class Withdrawals(RootModel[list[Withdrawal]]):
654
+ """List model for withdrawal history responses."""
655
+
656
+ model_config = ConfigDict(
657
+ frozen=True,
658
+ str_strip_whitespace=True,
659
+ validate_assignment=True,
660
+ )
661
+
662
+ def by_symbol(self, symbol: str) -> list[Withdrawal]:
663
+ return [withdrawal for withdrawal in self.root if withdrawal.symbol == symbol]
664
+
665
+ def by_status(self, status: str) -> list[Withdrawal]:
666
+ return [withdrawal for withdrawal in self.root if withdrawal.status == status]
667
+
668
+
669
+ class WithdrawResponse(BaseModel):
670
+ """Response model for withdraw assets operation."""
671
+
672
+ model_config = ConfigDict(
673
+ frozen=True,
674
+ extra="forbid",
675
+ str_strip_whitespace=True,
676
+ validate_assignment=True,
677
+ )
678
+
679
+ success: bool = Field(..., description="Indicates if the withdrawal request was successful")
680
+ symbol: str = Field(..., description="The asset that was withdrawn")
681
+ amount: str = Field(..., description="The total amount deducted from your balance")
682
+
683
+ @field_validator("amount")
684
+ @classmethod
685
+ def _numeric_str(cls, v: str) -> str:
686
+ try:
687
+ d = Decimal(v)
688
+ except Exception as exc:
689
+ msg = "must be a numeric string"
690
+ raise ValueError(msg) from exc
691
+ if d < 0:
692
+ msg = "must be non-negative"
693
+ raise ValueError(msg)
694
+ return v
695
+
696
+ def amount_decimal(self) -> Decimal:
697
+ return Decimal(self.amount)
698
+
699
+
700
+ class DepositDigital(BaseModel):
701
+ """Digital asset deposit data response."""
702
+
703
+ model_config = ConfigDict(
704
+ frozen=True,
705
+ extra="forbid",
706
+ str_strip_whitespace=True,
707
+ validate_assignment=True,
708
+ )
709
+
710
+ address: str = Field(..., description="The address where to deposit assets")
711
+ paymentid: str | None = Field(None, description="Payment ID if required (also called note, memo, or tag)")
712
+
713
+
714
+ class DepositFiat(BaseModel):
715
+ """Fiat deposit data response."""
716
+
717
+ model_config = ConfigDict(
718
+ frozen=True,
719
+ extra="forbid",
720
+ str_strip_whitespace=True,
721
+ validate_assignment=True,
722
+ )
723
+
724
+ iban: str = Field(..., description="International bank account number where to deposit assets")
725
+ bic: str = Field(..., description="Bank identification code sometimes necessary for international transfers")
726
+ description: str = Field(..., description="Description which must be used for the deposit")
727
+
728
+
729
+ class Deposit(BaseModel):
730
+ """Union type for deposit data responses that handles both digital and fiat deposit information."""
731
+
732
+ model_config = ConfigDict(
733
+ frozen=True,
734
+ extra="forbid",
735
+ str_strip_whitespace=True,
736
+ validate_assignment=True,
737
+ )
738
+
739
+ # For digital assets
740
+ address: str | None = Field(None, description="The address where to deposit assets")
741
+ paymentid: str | None = Field(None, description="Payment ID if required (also called note, memo, or tag)")
742
+
743
+ # For fiat
744
+ iban: str | None = Field(None, description="International bank account number where to deposit assets")
745
+ bic: str | None = Field(
746
+ None, description="Bank identification code sometimes necessary for international transfers"
747
+ )
748
+ description: str | None = Field(None, description="Description which must be used for the deposit")
749
+
750
+ @field_validator("address", "iban", mode="before")
751
+ @classmethod
752
+ def _ensure_non_empty_strings(cls, v: str | None) -> str | None:
753
+ """Ensure address and iban are non-empty if provided."""
754
+ if v is not None and isinstance(v, str) and not v.strip():
755
+ msg = "Address and IBAN cannot be empty strings"
756
+ raise ValueError(msg)
757
+ return v
758
+
759
+ def is_digital(self) -> bool:
760
+ """Check if this is digital asset deposit data."""
761
+ return self.address is not None
762
+
763
+ def is_fiat(self) -> bool:
764
+ """Check if this is fiat deposit data."""
765
+ return self.iban is not None
766
+
767
+
768
+ class TransactionHistoryItem(BaseModel):
769
+ """Single transaction from account transaction history."""
770
+
771
+ model_config = ConfigDict(
772
+ frozen=True,
773
+ str_strip_whitespace=True,
774
+ validate_assignment=True,
775
+ )
776
+
777
+ transaction_id: str = Field(..., alias="transactionId", description="The unique identifier of the transaction")
778
+ executed_at: str = Field(
779
+ ..., alias="executedAt", description="The Unix timestamp when the transaction was executed"
780
+ )
781
+ type: Literal[
782
+ "sell",
783
+ "buy",
784
+ "staking",
785
+ "fixed_staking",
786
+ "deposit",
787
+ "withdrawal",
788
+ "affiliate",
789
+ "distribution",
790
+ "internal_transfer",
791
+ "withdrawal_cancelled",
792
+ "rebate",
793
+ "loan",
794
+ "external_transferred_funds",
795
+ "manually_assigned_bitvavo",
796
+ ] = Field(..., description="The type of transaction")
797
+ price_currency: str | None = Field(
798
+ None, alias="priceCurrency", description="The currency in which the transaction was made"
799
+ )
800
+ price_amount: str | None = Field(None, alias="priceAmount", description="The amount of the transaction")
801
+ sent_currency: str | None = Field(
802
+ None, alias="sentCurrency", description="The currency that was sent in the transaction"
803
+ )
804
+ sent_amount: str | None = Field(None, alias="sentAmount", description="The amount that was sent in the transaction")
805
+ received_currency: str | None = Field(
806
+ None, alias="receivedCurrency", description="The currency that was received in the transaction"
807
+ )
808
+ received_amount: str | None = Field(
809
+ None, alias="receivedAmount", description="The amount that was received in the transaction"
810
+ )
811
+ fees_currency: str | None = Field(
812
+ None, alias="feesCurrency", description="The currency in which the fees were paid"
813
+ )
814
+ fees_amount: str | None = Field(None, alias="feesAmount", description="The amount of fees paid in the transaction")
815
+ address: str | None = Field(None, description="The address where the transaction was made")
816
+
817
+ @field_validator("price_amount", "sent_amount", "received_amount", "fees_amount")
818
+ @classmethod
819
+ def _numeric_str(cls, v: str | None) -> str | None:
820
+ if v is None:
821
+ return v
822
+ try:
823
+ d = Decimal(v)
824
+ except Exception as exc:
825
+ msg = "must be a numeric string"
826
+ raise ValueError(msg) from exc
827
+ if d < 0:
828
+ msg = "must be non-negative"
829
+ raise ValueError(msg)
830
+ return v
831
+
832
+ def price_amount_decimal(self) -> Decimal:
833
+ return Decimal(self.price_amount) if self.price_amount is not None else Decimal(0)
834
+
835
+ def sent_amount_decimal(self) -> Decimal:
836
+ return Decimal(self.sent_amount) if self.sent_amount is not None else Decimal(0)
837
+
838
+ def received_amount_decimal(self) -> Decimal:
839
+ return Decimal(self.received_amount) if self.received_amount is not None else Decimal(0)
840
+
841
+ def fees_amount_decimal(self) -> Decimal:
842
+ return Decimal(self.fees_amount) if self.fees_amount is not None else Decimal(0)
843
+
844
+
845
+ class TransactionHistoryMetadata(BaseModel):
846
+ """Metadata for transaction history pagination."""
847
+
848
+ model_config = ConfigDict(
849
+ frozen=True,
850
+ str_strip_whitespace=True,
851
+ validate_assignment=True,
852
+ )
853
+
854
+ current_page: int = Field(..., alias="currentPage", description="The current page number")
855
+ total_pages: int = Field(..., alias="totalPages", description="The total number of returned pages")
856
+ max_items: int = Field(..., alias="maxItems", description="The maximum number of transactions per page")
857
+
858
+ @field_validator("current_page", "total_pages", "max_items")
859
+ @classmethod
860
+ def _positive_int(cls, v: int) -> int:
861
+ if v < 1:
862
+ msg = "must be positive"
863
+ raise ValueError(msg)
864
+ return v
865
+
866
+
867
+ class TransactionHistory(RootModel[list[TransactionHistoryItem]]):
868
+ """List model for transaction history items."""
869
+
870
+ model_config = ConfigDict(
871
+ frozen=True,
872
+ str_strip_whitespace=True,
873
+ validate_assignment=True,
874
+ )
875
+
876
+ def by_type(self, transaction_type: str) -> list[TransactionHistoryItem]:
877
+ """Filter transactions by type."""
878
+ return [tx for tx in self.root if tx.type == transaction_type]
879
+
880
+ def by_currency(self, currency: str) -> list[TransactionHistoryItem]:
881
+ """Filter transactions involving a specific currency."""
882
+ return [
883
+ tx
884
+ for tx in self.root
885
+ if currency in (tx.price_currency, tx.sent_currency, tx.received_currency, tx.fees_currency)
886
+ ]