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,1087 @@
1
+ """Pydantic models for validating API responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator
9
+
10
+
11
+ class ServerTime(BaseModel):
12
+ """Example Pydantic model for server time response."""
13
+
14
+ model_config = ConfigDict(
15
+ frozen=True,
16
+ extra="forbid",
17
+ str_strip_whitespace=True,
18
+ )
19
+
20
+ time: int = Field(..., description="Server timestamp in milliseconds", gt=0)
21
+ time_ns: int = Field(..., alias="timeNs", description="Server timestamp in nanoseconds", gt=0)
22
+
23
+
24
+ class Market(BaseModel):
25
+ """Pydantic model for a single market entry from the /markets endpoint."""
26
+
27
+ model_config = ConfigDict(
28
+ frozen=True,
29
+ extra="forbid",
30
+ populate_by_name=True, # Allows using both field names and aliases
31
+ str_strip_whitespace=True,
32
+ validate_assignment=True, # Re-validate on assignment (useful for frozen=True)
33
+ )
34
+
35
+ market: str = Field(..., description="Market symbol (e.g., 'BTC-EUR')", min_length=1)
36
+ status: str = Field(..., description="Market status (e.g., 'trading', 'halted')")
37
+ base: str = Field(..., description="Base asset symbol", min_length=1)
38
+ quote: str = Field(..., description="Quote asset symbol", min_length=1)
39
+ price_precision: int = Field(..., alias="pricePrecision", description="Price precision", ge=0)
40
+ min_order_in_base_asset: str = Field(
41
+ ...,
42
+ alias="minOrderInBaseAsset",
43
+ description="Minimum order size in base asset",
44
+ )
45
+ min_order_in_quote_asset: str = Field(
46
+ ...,
47
+ alias="minOrderInQuoteAsset",
48
+ description="Minimum order size in quote asset",
49
+ )
50
+ max_order_in_base_asset: str = Field(
51
+ ...,
52
+ alias="maxOrderInBaseAsset",
53
+ description="Maximum order size in base asset",
54
+ )
55
+ max_order_in_quote_asset: str = Field(
56
+ ...,
57
+ alias="maxOrderInQuoteAsset",
58
+ description="Maximum order size in quote asset",
59
+ )
60
+ quantity_decimals: int = Field(..., alias="quantityDecimals", description="Quantity decimal places", ge=0)
61
+ notional_decimals: int = Field(..., alias="notionalDecimals", description="Notional decimal places", ge=0)
62
+ tick_size: str | None = Field(default=None, alias="tickSize", description="Minimum price increment")
63
+ max_open_orders: int = Field(..., alias="maxOpenOrders", description="Maximum open orders", ge=0)
64
+ fee_category: str = Field(..., alias="feeCategory", description="Fee category")
65
+ order_types: list[str] = Field(..., alias="orderTypes", description="Supported order types", min_length=1)
66
+
67
+ @field_validator("order_types")
68
+ @classmethod
69
+ def validate_order_types(cls, v: list[str]) -> list[str]:
70
+ """Ensure all order types are non-empty strings."""
71
+ if not all(isinstance(order_type, str) and order_type.strip() for order_type in v):
72
+ msg = "All order types must be non-empty strings"
73
+ raise ValueError(msg)
74
+ return v
75
+
76
+
77
+ class Markets(RootModel[list[Market]]):
78
+ """Wrapper model representing a list of Market objects (API /markets response)."""
79
+
80
+ model_config = ConfigDict(
81
+ frozen=True,
82
+ populate_by_name=True,
83
+ validate_assignment=True,
84
+ )
85
+
86
+ def __len__(self) -> int:
87
+ """Return the number of markets."""
88
+ return len(self.root)
89
+
90
+ def __getitem__(self, index: int) -> Market:
91
+ """Allow indexing into the markets list."""
92
+ return self.root[index]
93
+
94
+ @property
95
+ def markets(self) -> list[Market]:
96
+ """Get the underlying list of markets."""
97
+ return self.root
98
+
99
+ def get_market(self, symbol: str) -> Market | None:
100
+ """Get a market by symbol.
101
+
102
+ Args:
103
+ symbol: Market symbol (e.g., 'BTC-EUR')
104
+
105
+ Returns:
106
+ Market model if found, None otherwise
107
+ """
108
+ for market in self.root:
109
+ if market.market == symbol:
110
+ return market
111
+ return None
112
+
113
+ def filter_by_status(self, status: str) -> list[Market]:
114
+ """Filter markets by status.
115
+
116
+ Args:
117
+ status: Status to filter by (e.g., 'trading')
118
+
119
+ Returns:
120
+ List of markets with the specified status
121
+ """
122
+ return [market for market in self.root if market.status == status]
123
+
124
+ def get_base_assets(self) -> set[str]:
125
+ """Get all unique base assets.
126
+
127
+ Returns:
128
+ Set of base asset symbols
129
+ """
130
+ return {market.base for market in self.root}
131
+
132
+ def get_quote_assets(self) -> set[str]:
133
+ """Get all unique quote assets.
134
+
135
+ Returns:
136
+ Set of quote asset symbols
137
+ """
138
+ return {market.quote for market in self.root}
139
+
140
+
141
+ class Asset(BaseModel):
142
+ """Pydantic model for a single asset/currency entry (from the /assets endpoint)."""
143
+
144
+ model_config = ConfigDict(
145
+ frozen=True,
146
+ extra="forbid",
147
+ str_strip_whitespace=True,
148
+ validate_assignment=True,
149
+ )
150
+
151
+ symbol: str = Field(
152
+ ...,
153
+ description="Asset symbol (e.g. 'BTC')",
154
+ min_length=1,
155
+ )
156
+ name: str = Field(
157
+ ...,
158
+ description="Human readable name",
159
+ )
160
+ decimals: int = Field(
161
+ ...,
162
+ description="Decimal places supported",
163
+ ge=0,
164
+ )
165
+ deposit_fee: str = Field(
166
+ ...,
167
+ alias="depositFee",
168
+ description="Deposit fee as string (e.g. '0')",
169
+ )
170
+ deposit_confirmations: int = Field(
171
+ ...,
172
+ alias="depositConfirmations",
173
+ description="Required confirmations for deposit",
174
+ ge=0,
175
+ )
176
+ deposit_status: str = Field(
177
+ ...,
178
+ alias="depositStatus",
179
+ description="Deposit status (e.g. 'OK', 'MAINTENANCE', 'DELISTED')",
180
+ )
181
+ withdrawal_fee: str = Field(..., alias="withdrawalFee", description="Withdrawal fee as string")
182
+ withdrawal_min_amount: str = Field(
183
+ ...,
184
+ alias="withdrawalMinAmount",
185
+ description="Minimum withdrawal amount as string",
186
+ )
187
+ withdrawal_status: str = Field(
188
+ ...,
189
+ alias="withdrawalStatus",
190
+ description="Withdrawal status (e.g. 'OK', 'MAINTENANCE', 'DELISTED')",
191
+ )
192
+ networks: list[str] = Field(
193
+ ...,
194
+ description="List of supported networks (e.g. ['Mainnet', 'ETH'])",
195
+ )
196
+ message: str = Field(
197
+ ...,
198
+ description="Optional message from the API",
199
+ min_length=0,
200
+ )
201
+
202
+ @field_validator("symbol", "name")
203
+ @classmethod
204
+ def non_empty_str(cls, v: str) -> str:
205
+ if not v or not v.strip():
206
+ msg = "must be a non-empty string"
207
+ raise ValueError(msg)
208
+ return v
209
+
210
+ @field_validator("networks")
211
+ @classmethod
212
+ def validate_networks(cls, v: list[str]) -> list[str]:
213
+ """Ensure all networks are non-empty strings."""
214
+ if not all(isinstance(network, str) and network.strip() for network in v):
215
+ msg = "All networks must be non-empty strings"
216
+ raise ValueError(msg)
217
+ return v
218
+
219
+ @field_validator("deposit_fee", "withdrawal_fee", "withdrawal_min_amount", "deposit_confirmations", mode="before")
220
+ @classmethod
221
+ def normalize_fee_strings(cls, v: Any) -> Any:
222
+ # keep fees/min-amounts as strings as the API returns them,
223
+ # but ensure they are not None. Leave validation of numeric format
224
+ # to higher-level code/tests if needed.
225
+ if v is None:
226
+ msg = "fee/min-amount fields must be provided"
227
+ raise ValueError(msg)
228
+ return v
229
+
230
+
231
+ class Assets(RootModel[list[Asset]]):
232
+ """Wrapper model representing a list of Asset objects (API /assets response)."""
233
+
234
+ model_config = ConfigDict(
235
+ frozen=True,
236
+ validate_assignment=True,
237
+ )
238
+
239
+ def __len__(self) -> int:
240
+ return len(self.root)
241
+
242
+ def __getitem__(self, index: int) -> Asset:
243
+ return self.root[index]
244
+
245
+ @property
246
+ def assets(self) -> list[Asset]:
247
+ return self.root
248
+
249
+ def get_asset(self, symbol: str) -> Asset | None:
250
+ """Return the Asset with matching symbol or None."""
251
+ for asset in self.root:
252
+ if asset.symbol == symbol:
253
+ return asset
254
+ return None
255
+
256
+ def filter_by_deposit_status(self, status: str) -> list[Asset]:
257
+ """Return assets filtered by depositStatus."""
258
+ return [a for a in self.root if a.deposit_status == status]
259
+
260
+ def filter_by_withdrawal_status(self, status: str) -> list[Asset]:
261
+ """Return assets filtered by withdrawalStatus."""
262
+ return [a for a in self.root if a.withdrawal_status == status]
263
+
264
+
265
+ class PriceLevel(RootModel[list[str]]):
266
+ """A single price level represented as a list (e.g. [price, amount, ...]).
267
+
268
+ The Bitvavo API returns bids/asks as nested lists. We normalize each inner
269
+ list to a list of stripped strings and require at least price and amount.
270
+ """
271
+
272
+ model_config = ConfigDict(frozen=True, str_strip_whitespace=True)
273
+
274
+ @field_validator("root", mode="before")
275
+ @classmethod
276
+ def _ensure_list_of_str(cls, v: Any) -> list[str]:
277
+ # Accept tuples/lists and also already-parsed PriceLevel instances.
278
+ if isinstance(v, PriceLevel):
279
+ return v.root
280
+ try:
281
+ items = list(v)
282
+ except TypeError as exc:
283
+ msg = "price level must be an iterable"
284
+ raise ValueError(msg) from exc
285
+ if len(items) < 2: # noqa: PLR2004
286
+ msg = "price level must contain at least price and amount"
287
+ raise ValueError(msg)
288
+ # Convert all items to strings and strip whitespace
289
+ return [("" if it is None else str(it)).strip() for it in items]
290
+
291
+ def price(self) -> str:
292
+ return self.root[0]
293
+
294
+ def amount(self) -> str:
295
+ return self.root[1]
296
+
297
+ def extras(self) -> list[str]:
298
+ return self.root[2:]
299
+
300
+
301
+ class OrderBook(BaseModel):
302
+ """Model for the order book snapshot returned by the API.
303
+
304
+ Example input:
305
+ {'market': 'BTC-EUR', 'nonce': 91722611, 'bids': [[...], ...], 'asks': [[...], ...], 'timestamp': 1756...}
306
+ """
307
+
308
+ model_config = ConfigDict(
309
+ frozen=True,
310
+ extra="forbid",
311
+ str_strip_whitespace=True,
312
+ populate_by_name=True,
313
+ validate_assignment=True,
314
+ )
315
+
316
+ market: str = Field(..., description="Market symbol (e.g. 'BTC-EUR')", min_length=1)
317
+ nonce: int = Field(..., description="Snapshot nonce", ge=0)
318
+ bids: list[PriceLevel] = Field(..., description="List of bid price levels (each is a list)")
319
+ asks: list[PriceLevel] = Field(..., description="List of ask price levels (each is a list)")
320
+ timestamp: int = Field(..., description="Server timestamp (likely in nanoseconds)", ge=0)
321
+
322
+ @field_validator("bids", "asks", mode="before")
323
+ @classmethod
324
+ def _normalize_levels(cls, v: Any) -> list[PriceLevel]:
325
+ # Ensure iterable -> list and let PriceLevel parse each inner entry
326
+ try:
327
+ return list(v)
328
+ except TypeError as exc:
329
+ msg = "bids/asks must be iterable of price levels"
330
+ raise ValueError(msg) from exc
331
+
332
+ def best_bid(self) -> PriceLevel | None:
333
+ return self.bids[0] if self.bids else None
334
+
335
+ def best_ask(self) -> PriceLevel | None:
336
+ return self.asks[0] if self.asks else None
337
+
338
+
339
+ class TickerPrice(BaseModel):
340
+ """Model representing a single market ticker price entry."""
341
+
342
+ model_config = ConfigDict(
343
+ frozen=True,
344
+ extra="forbid",
345
+ str_strip_whitespace=True,
346
+ validate_assignment=True,
347
+ )
348
+
349
+ market: str = Field(..., description="Market symbol (e.g. 'BTC-EUR')", min_length=1)
350
+ price: str = Field(..., description="Price as returned by the API (string)")
351
+
352
+ @field_validator("market", "price")
353
+ @classmethod
354
+ def _non_empty_str(cls, v: str) -> str:
355
+ if not isinstance(v, str) or not v.strip():
356
+ msg = "must be a non-empty string"
357
+ raise ValueError(msg)
358
+ return v
359
+
360
+ @field_validator("price")
361
+ @classmethod
362
+ def _validate_price_is_numeric(cls, v: str) -> str:
363
+ # Keep the original string value but ensure it is a numeric representation
364
+ try:
365
+ d = Decimal(v)
366
+ except Exception as exc:
367
+ msg = "price must be a numeric string"
368
+ raise ValueError(msg) from exc
369
+ if d < 0:
370
+ msg = "price must be non-negative"
371
+ raise ValueError(msg)
372
+ return v
373
+
374
+
375
+ class TickerPrices(RootModel[list[TickerPrice]]):
376
+ """Wrapper for a list of TickerPrice items (e.g. API tickers response)."""
377
+
378
+ model_config = ConfigDict(
379
+ frozen=True,
380
+ validate_assignment=True,
381
+ )
382
+
383
+ def __len__(self) -> int:
384
+ return len(self.root)
385
+
386
+ def __getitem__(self, index: int) -> TickerPrice:
387
+ return self.root[index]
388
+
389
+ @property
390
+ def prices(self) -> list[TickerPrice]:
391
+ return self.root
392
+
393
+ def get_price(self, market: str) -> TickerPrice | None:
394
+ """Return the TickerPrice for the given market or None if not found."""
395
+ for tp in self.root:
396
+ if tp.market == market:
397
+ return tp
398
+ return None
399
+
400
+ def to_serializable(self) -> list[dict]:
401
+ """Return a list of dicts suitable for JSON serialization."""
402
+ return [tp.model_dump() for tp in self.root]
403
+
404
+
405
+ class TickerBook(BaseModel):
406
+ """Model representing best bid/ask for a single market (API /ticker/book)."""
407
+
408
+ model_config = ConfigDict(
409
+ frozen=True,
410
+ extra="forbid",
411
+ str_strip_whitespace=True,
412
+ validate_assignment=True,
413
+ )
414
+
415
+ market: str = Field(..., description="Market symbol (e.g. 'BTC-EUR')", min_length=1)
416
+ bid: str | None = Field(..., description="Best bid price as string")
417
+ bid_size: str | None = Field(..., alias="bidSize", description="Size available at best bid as string")
418
+ ask: str | None = Field(..., description="Best ask price as string")
419
+ ask_size: str | None = Field(..., alias="askSize", description="Size available at best ask as string")
420
+
421
+ @field_validator("market")
422
+ @classmethod
423
+ def _non_empty_str(cls, v: str) -> str:
424
+ if not isinstance(v, str) or not v.strip():
425
+ msg = "must be a non-empty string"
426
+ raise ValueError(msg)
427
+ return v
428
+
429
+ @field_validator("bid", "ask", "bid_size", "ask_size")
430
+ @classmethod
431
+ def _validate_numeric_str(cls, v: str | None) -> str | None:
432
+ if v is None:
433
+ return v
434
+ try:
435
+ d = Decimal(v)
436
+ except Exception as exc:
437
+ msg = "must be a numeric string"
438
+ raise ValueError(msg) from exc
439
+ if d < 0:
440
+ msg = "must be non-negative"
441
+ raise ValueError(msg)
442
+ return v
443
+
444
+
445
+ class TickerBooks(RootModel[list[TickerBook]]):
446
+ """Wrapper for a list of TickerBook items (e.g. API /ticker/book response).
447
+
448
+ Handles both list responses (when no market is specified) and single object
449
+ responses (when a specific market is requested via query parameter).
450
+ """
451
+
452
+ model_config = ConfigDict(
453
+ frozen=True,
454
+ validate_assignment=True,
455
+ )
456
+
457
+ @field_validator("root", mode="before")
458
+ @classmethod
459
+ def _normalize_input(cls, v: Any) -> list[dict]:
460
+ """Convert single TickerBook dict to list for consistent handling."""
461
+ if isinstance(v, dict):
462
+ # Single ticker book object - wrap in list
463
+ return [v]
464
+ if isinstance(v, list):
465
+ # Already a list of ticker books
466
+ return v
467
+ msg = "Input must be a dict or list of dicts"
468
+ raise TypeError(msg)
469
+
470
+ def __len__(self) -> int:
471
+ return len(self.root)
472
+
473
+ def __getitem__(self, index: int) -> TickerBook:
474
+ return self.root[index]
475
+
476
+ @property
477
+ def books(self) -> list[TickerBook]:
478
+ return self.root
479
+
480
+ def get_book(self, market: str) -> TickerBook | None:
481
+ """Return the TickerBook for the given market or None if not found."""
482
+ for tb in self.root:
483
+ if tb.market == market:
484
+ return tb
485
+ return None
486
+
487
+ def to_serializable(self) -> list[dict]:
488
+ """Return a list of dicts suitable for JSON serialization."""
489
+ return [tb.model_dump() for tb in self.root]
490
+
491
+
492
+ class Trade(BaseModel):
493
+ """Public trade entry (as returned by Bitvavo /trades)."""
494
+
495
+ model_config = ConfigDict(
496
+ frozen=True,
497
+ extra="forbid",
498
+ str_strip_whitespace=True,
499
+ validate_assignment=True,
500
+ )
501
+
502
+ id: str = Field(..., description="Trade identifier", min_length=1)
503
+ timestamp: int = Field(..., description="Trade timestamp in milliseconds", ge=0)
504
+ amount: str = Field(..., description="Traded amount (base asset) as string")
505
+ price: str = Field(..., description="Trade price as string")
506
+ side: str = Field(..., description="Trade side ('buy' or 'sell')")
507
+
508
+ @field_validator("id")
509
+ @classmethod
510
+ def _non_empty_id(cls, v: str) -> str:
511
+ if not isinstance(v, str) or not v.strip():
512
+ msg = "must be a non-empty string"
513
+ raise ValueError(msg)
514
+ return v
515
+
516
+ @field_validator("amount", "price")
517
+ @classmethod
518
+ def _numeric_str(cls, v: str) -> str:
519
+ try:
520
+ d = Decimal(v)
521
+ except Exception as exc:
522
+ msg = "must be a numeric string"
523
+ raise ValueError(msg) from exc
524
+ if d < 0:
525
+ msg = "must be non-negative"
526
+ raise ValueError(msg)
527
+ return v
528
+
529
+ @field_validator("side")
530
+ @classmethod
531
+ def _validate_side(cls, v: str) -> str:
532
+ if not isinstance(v, str) or not v.strip():
533
+ msg = "must be a non-empty string"
534
+ raise ValueError(msg)
535
+ side = v.strip().lower()
536
+ if side not in {"buy", "sell"}:
537
+ msg = "side must be 'buy' or 'sell'"
538
+ raise ValueError(msg)
539
+ return side
540
+
541
+
542
+ class Trades(RootModel[list[Trade]]):
543
+ """Wrapper for a list of Trade items."""
544
+
545
+ model_config = ConfigDict(
546
+ frozen=True,
547
+ validate_assignment=True,
548
+ )
549
+
550
+ def __len__(self) -> int:
551
+ return len(self.root)
552
+
553
+ def __getitem__(self, index: int) -> Trade:
554
+ return self.root[index]
555
+
556
+ @property
557
+ def trades(self) -> list[Trade]:
558
+ return self.root
559
+
560
+ def filter_by_side(self, side: str) -> list[Trade]:
561
+ s = side.strip().lower()
562
+ if s not in {"buy", "sell"}:
563
+ return []
564
+ return [t for t in self.root if t.side == s]
565
+
566
+ def buys(self) -> list[Trade]:
567
+ return self.filter_by_side("buy")
568
+
569
+ def sells(self) -> list[Trade]:
570
+ return self.filter_by_side("sell")
571
+
572
+ def latest(self) -> Trade | None:
573
+ return max(self.root, key=lambda t: t.timestamp) if self.root else None
574
+
575
+ def to_serializable(self) -> list[dict]:
576
+ return [t.model_dump() for t in self.root]
577
+
578
+
579
+ class Candle(BaseModel):
580
+ """Single OHLCV candle: [timestamp, open, high, low, close, volume]."""
581
+
582
+ model_config = ConfigDict(
583
+ frozen=True,
584
+ extra="forbid",
585
+ str_strip_whitespace=True,
586
+ validate_assignment=True,
587
+ )
588
+
589
+ timestamp: int = Field(..., description="Candle open time in milliseconds", ge=0)
590
+ open: str = Field(..., description="Open price as string")
591
+ high: str = Field(..., description="High price as string")
592
+ low: str = Field(..., description="Low price as string")
593
+ close: str = Field(..., description="Close price as string")
594
+ volume: str = Field(..., description="Volume as string")
595
+
596
+ @field_validator("open", "high", "low", "close", "volume")
597
+ @classmethod
598
+ def _validate_numeric_str(cls, v: str) -> str:
599
+ try:
600
+ d = Decimal(v)
601
+ except Exception as exc:
602
+ msg = "must be a numeric string"
603
+ raise ValueError(msg) from exc
604
+ if d < 0:
605
+ msg = "must be non-negative"
606
+ raise ValueError(msg)
607
+ return v
608
+
609
+ @classmethod
610
+ def from_ohlcv(cls, ohlcv: list | tuple) -> Candle:
611
+ """Create Candle from a 6-item sequence."""
612
+ if not isinstance(ohlcv, (list, tuple)) or len(ohlcv) < 6: # noqa: PLR2004
613
+ msg = "ohlcv must be a sequence [timestamp, open, high, low, close, volume]"
614
+ raise ValueError(msg)
615
+ timestamp, open, high, low, close, volume = ohlcv[:6] # noqa: A001
616
+ return cls(
617
+ timestamp=int(timestamp), open=str(open), high=str(high), low=str(low), close=str(close), volume=str(volume)
618
+ )
619
+
620
+ def to_ohlcv(self) -> list:
621
+ """Return as [timestamp, open, high, low, close, volume]."""
622
+ return [self.timestamp, self.open, self.high, self.low, self.close, self.volume]
623
+
624
+
625
+ class Candles(RootModel[list[Candle]]):
626
+ """Wrapper for list of Candle items (API /candles response)."""
627
+
628
+ model_config = ConfigDict(
629
+ frozen=True,
630
+ validate_assignment=True,
631
+ )
632
+
633
+ @field_validator("root", mode="before")
634
+ @classmethod
635
+ def _normalize_ohlcv(cls, v: Any) -> Any:
636
+ # Accept list of lists/tuples and convert each to Candle
637
+ try:
638
+ items = list(v)
639
+ except TypeError:
640
+ return v
641
+ out: list[Candle | dict] = []
642
+ for item in items:
643
+ if isinstance(item, Candle):
644
+ out.append(item)
645
+ elif isinstance(item, (list, tuple)):
646
+ timestamp, open, high, low, close, volume = (item + [None] * 6)[:6] # noqa: A001
647
+ if None in (timestamp, open, high, low, close, volume):
648
+ msg = "each candle must have 6 elements"
649
+ raise ValueError(msg)
650
+ out.append(
651
+ {
652
+ "timestamp": int(timestamp),
653
+ "open": str(open),
654
+ "high": str(high),
655
+ "low": str(low),
656
+ "close": str(close),
657
+ "volume": str(volume),
658
+ }
659
+ )
660
+ else:
661
+ out.append(item)
662
+ return out
663
+
664
+ def __len__(self) -> int:
665
+ return len(self.root)
666
+
667
+ def __getitem__(self, index: int) -> Candle:
668
+ return self.root[index]
669
+
670
+ @property
671
+ def candles(self) -> list[Candle]:
672
+ return self.root
673
+
674
+ def to_ohlcv(self) -> list[list]:
675
+ """Return list of [timestamp, open, high, low, close, volume]."""
676
+ return [c.to_ohlcv() for c in self.root]
677
+
678
+ def earliest(self) -> Candle | None:
679
+ return min(self.root, key=lambda c: c.timestamp) if self.root else None
680
+
681
+ def latest(self) -> Candle | None:
682
+ return max(self.root, key=lambda c: c.timestamp) if self.root else None
683
+
684
+
685
+ class Ticker24h(BaseModel):
686
+ """24h ticker stats for a single market (mirrors Bitvavo /ticker/24h item)."""
687
+
688
+ model_config = ConfigDict(
689
+ frozen=True,
690
+ extra="forbid",
691
+ str_strip_whitespace=True,
692
+ validate_assignment=True,
693
+ populate_by_name=True,
694
+ )
695
+
696
+ market: str = Field(..., description="Market symbol (e.g. 'BTC-EUR')", min_length=1)
697
+ start_timestamp: int = Field(..., alias="startTimestamp", description="Window start timestamp (ms)", ge=0)
698
+ timestamp: int = Field(..., description="Current server timestamp (ms)", ge=0)
699
+ open: str | None = Field(..., description="Open price as string")
700
+ open_timestamp: int | None = Field(..., alias="openTimestamp", description="Open price timestamp (ms)", ge=0)
701
+ high: str | None = Field(..., description="High price as string")
702
+ low: str | None = Field(..., description="Low price as string")
703
+ last: str | None = Field(..., description="Last trade price as string")
704
+ close_timestamp: int | None = Field(..., alias="closeTimestamp", description="Close price timestamp (ms)", ge=0)
705
+ bid: str | None = Field(..., description="Best bid price as string")
706
+ bid_size: str | None = Field(..., alias="bidSize", description="Size available at best bid as string")
707
+ ask: str | None = Field(..., description="Best ask price as string")
708
+ ask_size: str | None = Field(..., alias="askSize", description="Size available at best ask as string")
709
+ volume: str | None = Field(..., description="Base asset volume in the last 24h as string")
710
+ volume_quote: str | None = Field(
711
+ ...,
712
+ alias="volumeQuote",
713
+ description="Quote asset volume in the last 24h as string",
714
+ )
715
+
716
+ @field_validator("market")
717
+ @classmethod
718
+ def _non_empty_str(cls, v: str) -> str:
719
+ if not isinstance(v, str) or not v.strip():
720
+ msg = "must be a non-empty string"
721
+ raise ValueError(msg)
722
+ return v
723
+
724
+
725
+ class Ticker24hs(RootModel[list[Ticker24h]]):
726
+ """Wrapper for a list of Ticker24h items (API /ticker/24h response)."""
727
+
728
+ model_config = ConfigDict(
729
+ frozen=True,
730
+ validate_assignment=True,
731
+ )
732
+
733
+ def __len__(self) -> int:
734
+ return len(self.root)
735
+
736
+ def __getitem__(self, index: int) -> Ticker24h:
737
+ return self.root[index]
738
+
739
+ @property
740
+ def tickers(self) -> list[Ticker24h]:
741
+ return self.root
742
+
743
+ def get_ticker(self, market: str) -> Ticker24h | None:
744
+ """Return the Ticker24h for the given market or None if not found."""
745
+ for t in self.root:
746
+ if t.market == market:
747
+ return t
748
+ return None
749
+
750
+ def to_serializable(self) -> list[dict]:
751
+ """Return a list of dicts suitable for JSON serialization."""
752
+ return [t.model_dump(by_alias=True) for t in self.root]
753
+
754
+
755
+ class OrderBookReportEntry(BaseModel):
756
+ """Individual order entry in a MiCA-compliant order book report."""
757
+
758
+ model_config = ConfigDict(
759
+ frozen=True,
760
+ extra="forbid",
761
+ str_strip_whitespace=True,
762
+ validate_assignment=True,
763
+ # Enhanced error reporting configuration
764
+ title="OrderBookReportEntry",
765
+ validate_default=True,
766
+ loc_by_alias=False,
767
+ )
768
+
769
+ side: str = Field(..., description="Order side: 'BUYI' for bids, 'SELL' for asks")
770
+ price: str = Field(..., description="Price value as decimal string")
771
+ quantity: str = Field(..., alias="size", description="Quantity value as decimal string")
772
+ num_orders: int = Field(..., alias="numOrders", description="Number of orders at this price level", ge=1)
773
+
774
+ @field_validator("side")
775
+ @classmethod
776
+ def _validate_side(cls, v: str) -> str:
777
+ if not isinstance(v, str) or not v.strip():
778
+ msg = f"Order side must be a non-empty string, got {type(v).__name__}: {v!r}"
779
+ raise ValueError(msg)
780
+ side = v.strip().upper()
781
+ if side not in {"BUYI", "SELL"}:
782
+ msg = (
783
+ f"Order side must be 'BUY' or 'SELL', got: {side!r}. "
784
+ "Valid values: BUYI (buy orders), SELL (sell orders)"
785
+ )
786
+ raise ValueError(msg)
787
+ return side
788
+
789
+ @field_validator("price", "quantity")
790
+ @classmethod
791
+ def _validate_numeric_str(cls, v: str) -> str:
792
+ if not isinstance(v, str) or not v.strip():
793
+ msg = f"Numeric field must be a non-empty string, got {type(v).__name__}: {v!r}"
794
+ raise ValueError(msg)
795
+ try:
796
+ d = Decimal(v)
797
+ except (ValueError, TypeError) as exc:
798
+ msg = f"Numeric field must be a valid decimal string (e.g., '123.45'), got: {v!r}. Error: {exc}"
799
+ raise ValueError(msg) from exc
800
+ if d < 0:
801
+ msg = f"Numeric field must be non-negative, got: {v!r} (value: {d})"
802
+ raise ValueError(msg)
803
+ return v
804
+
805
+
806
+ class OrderBookReport(BaseModel):
807
+ """MiCA-compliant order book report model (API /report/{market}/book response)."""
808
+
809
+ model_config = ConfigDict(
810
+ frozen=True,
811
+ extra="forbid",
812
+ str_strip_whitespace=True,
813
+ validate_assignment=True,
814
+ populate_by_name=True,
815
+ # Enhanced error reporting configuration
816
+ title="OrderBookReport",
817
+ validate_default=True,
818
+ loc_by_alias=False,
819
+ )
820
+
821
+ submission_timestamp: str = Field(
822
+ ...,
823
+ alias="submissionTimestamp",
824
+ description="Timestamp when order book is submitted to database (ISO 8601)",
825
+ )
826
+ asset_code: str = Field(..., alias="assetCode", description="DTI code or symbol of the asset")
827
+ asset_name: str = Field(..., alias="assetName", description="Full name of the asset")
828
+ bids: list[OrderBookReportEntry] = Field(..., description="List of buy orders")
829
+ asks: list[OrderBookReportEntry] = Field(..., description="List of sell orders")
830
+ price_currency: str = Field(..., alias="priceCurrency", description="DTI code of price currency")
831
+ price_notation: str = Field(..., alias="priceNotation", description="Price notation (always 'MONE')")
832
+ quantity_currency: str = Field(..., alias="quantityCurrency", description="Currency for quantity expression")
833
+ quantity_notation: str = Field(..., alias="quantityNotation", description="Quantity notation (always 'CRYP')")
834
+ venue: str = Field(..., description="Market Identifier Code (always 'VAVO')")
835
+ trading_system: str = Field(..., alias="tradingSystem", description="Trading system identifier (always 'VAVO')")
836
+ publication_timestamp: str = Field(
837
+ ...,
838
+ alias="publicationTimestamp",
839
+ description="Timestamp when book snapshot is added to database (ISO 8601)",
840
+ )
841
+
842
+ @field_validator("submission_timestamp", "publication_timestamp")
843
+ @classmethod
844
+ def _validate_timestamp(cls, v: str) -> str:
845
+ if not isinstance(v, str):
846
+ msg = "timestamp must be a string"
847
+ raise TypeError(msg)
848
+
849
+ # Allow empty timestamps
850
+ if not v.strip():
851
+ return v
852
+
853
+ # Basic format validation - should be ISO 8601 format
854
+ if not v.endswith("Z") or "T" not in v:
855
+ msg = "timestamp must be in ISO 8601 format (e.g., '2025-05-02T14:23:11.123456Z') or empty"
856
+ raise ValueError(msg)
857
+ return v
858
+
859
+ @field_validator("asset_code", "asset_name", "price_currency", "quantity_currency", "venue", "trading_system")
860
+ @classmethod
861
+ def _non_empty_str(cls, v: str) -> str:
862
+ if not isinstance(v, str) or not v.strip():
863
+ msg = "must be a non-empty string"
864
+ raise ValueError(msg)
865
+ return v
866
+
867
+ @field_validator("price_notation")
868
+ @classmethod
869
+ def _validate_price_notation(cls, v: str) -> str:
870
+ if not isinstance(v, str) or v.strip() != "MONE":
871
+ msg = "price_notation must be 'MONE'"
872
+ raise ValueError(msg)
873
+ return v
874
+
875
+ @field_validator("quantity_notation")
876
+ @classmethod
877
+ def _validate_quantity_notation(cls, v: str) -> str:
878
+ if not isinstance(v, str) or v.strip() != "CRYP":
879
+ msg = "quantity_notation must be 'CRYP'"
880
+ raise ValueError(msg)
881
+ return v
882
+
883
+ @field_validator("venue", "trading_system")
884
+ @classmethod
885
+ def _validate_venue_system(cls, v: str) -> str:
886
+ if not isinstance(v, str) or v.strip() not in ["VAVO", "CLOB"]:
887
+ msg = "venue and trading_system must be 'VAVO' or 'CLOB'"
888
+ raise ValueError(msg)
889
+ return v
890
+
891
+ def best_bid(self) -> OrderBookReportEntry | None:
892
+ """Get the best (highest) bid."""
893
+ if not self.bids:
894
+ return None
895
+ return max(self.bids, key=lambda bid: Decimal(bid.price))
896
+
897
+ def best_ask(self) -> OrderBookReportEntry | None:
898
+ """Get the best (lowest) ask."""
899
+ if not self.asks:
900
+ return None
901
+ return min(self.asks, key=lambda ask: Decimal(ask.price))
902
+
903
+ def spread(self) -> Decimal | None:
904
+ """Calculate the spread between best bid and ask."""
905
+ best_bid = self.best_bid()
906
+ best_ask = self.best_ask()
907
+ if best_bid and best_ask:
908
+ return Decimal(best_ask.price) - Decimal(best_bid.price)
909
+ return None
910
+
911
+
912
+ class TradeReportEntry(BaseModel):
913
+ """Individual trade entry in a MiCA-compliant trades report."""
914
+
915
+ model_config = ConfigDict(
916
+ frozen=True,
917
+ extra="forbid",
918
+ str_strip_whitespace=True,
919
+ validate_assignment=True,
920
+ populate_by_name=True,
921
+ )
922
+
923
+ trade_id: str = Field(
924
+ ...,
925
+ alias="tradeId",
926
+ description="The unique identifier of the trade",
927
+ min_length=1,
928
+ )
929
+ transact_timestamp: str = Field(
930
+ ...,
931
+ alias="transactTimestamp",
932
+ description="The timestamp when the trade is added to the database (ISO 8601 format)",
933
+ )
934
+ asset_code: str = Field(
935
+ ...,
936
+ alias="assetCode",
937
+ description="The DTI code or a symbol of the asset",
938
+ min_length=1,
939
+ )
940
+ asset_name: str = Field(
941
+ ...,
942
+ alias="assetName",
943
+ description="The full name of the asset",
944
+ min_length=1,
945
+ )
946
+ price: str = Field(
947
+ ...,
948
+ description="The price of 1 unit of base currency in the amount of quote currency at the time of the trade",
949
+ )
950
+ missing_price: str = Field(
951
+ "",
952
+ alias="missingPrice",
953
+ description="Indicates if the price is pending (PNDG) or not applicable (NOAP). May be empty.",
954
+ )
955
+ price_notation: str = Field(
956
+ ...,
957
+ alias="priceNotation",
958
+ description="Indicates whether the price is expressed as a monetary value, percentage, yield, or basis points",
959
+ )
960
+ price_currency: str = Field(
961
+ ...,
962
+ alias="priceCurrency",
963
+ description="The currency in which the price is expressed",
964
+ min_length=1,
965
+ )
966
+ quantity: str = Field(
967
+ ...,
968
+ description="The quantity of the asset (decimal string)",
969
+ )
970
+ quantity_currency: str = Field(
971
+ ...,
972
+ alias="quantityCurrency",
973
+ description="The currency in which the quantity of the crypto asset is expressed",
974
+ min_length=1,
975
+ )
976
+ quantity_notation: str = Field(
977
+ ...,
978
+ alias="quantityNotation",
979
+ description="Indicates whether the quantity is expressed as units, nominal value, monetary value, or crypto",
980
+ )
981
+ venue: str = Field(
982
+ ...,
983
+ description="The Market Identifier Code of the Bitvavo trading platform",
984
+ min_length=1,
985
+ )
986
+ publication_timestamp: str = Field(
987
+ ...,
988
+ alias="publicationTimestamp",
989
+ description="The timestamp when the trade is added to the database (ISO 8601 format)",
990
+ )
991
+ publication_venue: str = Field(
992
+ ...,
993
+ alias="publicationVenue",
994
+ description="The Market Identifier Code of the trading platform that publishes the transaction",
995
+ min_length=1,
996
+ )
997
+
998
+ @field_validator(
999
+ "trade_id", "asset_code", "asset_name", "price_currency", "quantity_currency", "venue", "publication_venue"
1000
+ )
1001
+ @classmethod
1002
+ def _non_empty_str(cls, v: str) -> str:
1003
+ if not isinstance(v, str) or not v.strip():
1004
+ msg = "must be a non-empty string"
1005
+ raise ValueError(msg)
1006
+ return v
1007
+
1008
+ @field_validator("price", "quantity")
1009
+ @classmethod
1010
+ def _numeric_str(cls, v: str) -> str:
1011
+ try:
1012
+ d = Decimal(v)
1013
+ except Exception as exc:
1014
+ msg = "must be a numeric string"
1015
+ raise ValueError(msg) from exc
1016
+ if d < 0:
1017
+ msg = "must be non-negative"
1018
+ raise ValueError(msg)
1019
+ return v
1020
+
1021
+ @field_validator("transact_timestamp", "publication_timestamp")
1022
+ @classmethod
1023
+ def _iso_timestamp(cls, v: str) -> str:
1024
+ if not isinstance(v, str):
1025
+ msg = "timestamp must be a string"
1026
+ raise TypeError(msg)
1027
+
1028
+ # Basic format validation - should be ISO 8601 format
1029
+ if not v.endswith("Z") or "T" not in v:
1030
+ msg = "timestamp must be in ISO 8601 format (e.g., '2024-05-02T14:43:11.123456Z')"
1031
+ raise ValueError(msg)
1032
+ return v
1033
+
1034
+ @field_validator("price_notation")
1035
+ @classmethod
1036
+ def _validate_price_notation(cls, v: str) -> str:
1037
+ if not isinstance(v, str) or v.strip() != "MONE":
1038
+ msg = "price_notation must be 'MONE'"
1039
+ raise ValueError(msg)
1040
+ return v
1041
+
1042
+ @field_validator("quantity_notation")
1043
+ @classmethod
1044
+ def _validate_quantity_notation(cls, v: str) -> str:
1045
+ if not isinstance(v, str) or v.strip() != "CRYP":
1046
+ msg = "quantity_notation must be 'CRYP'"
1047
+ raise ValueError(msg)
1048
+ return v
1049
+
1050
+ @field_validator("venue", "publication_venue")
1051
+ @classmethod
1052
+ def _validate_venue(cls, v: str) -> str:
1053
+ if not isinstance(v, str) or v.strip() != "VAVO":
1054
+ msg = "venue must be 'VAVO'"
1055
+ raise ValueError(msg)
1056
+ return v
1057
+
1058
+ @field_validator("missing_price")
1059
+ @classmethod
1060
+ def _validate_missing_price(cls, v: str) -> str:
1061
+ if not isinstance(v, str):
1062
+ msg = "missing_price must be a string"
1063
+ raise TypeError(msg)
1064
+
1065
+ # Allow empty string or specific values
1066
+ if v and v.strip() not in ["PNDG", "NOAP"]:
1067
+ msg = "missing_price must be empty, 'PNDG', or 'NOAP'"
1068
+ raise ValueError(msg)
1069
+ return v
1070
+
1071
+
1072
+ class TradesReport(RootModel[list[TradeReportEntry]]):
1073
+ """MiCA-compliant trades report model (API /report/{market}/trades response)."""
1074
+
1075
+ model_config = ConfigDict(
1076
+ frozen=True,
1077
+ validate_assignment=True,
1078
+ )
1079
+
1080
+ def __len__(self) -> int:
1081
+ return len(self.root)
1082
+
1083
+ def __iter__(self) -> Any:
1084
+ return iter(self.root)
1085
+
1086
+ def __getitem__(self, item: int) -> TradeReportEntry:
1087
+ return self.root[item]