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.
- bitvavo_api_upgraded/dataframe_utils.py +1 -1
- {bitvavo_api_upgraded-4.1.0.dist-info → bitvavo_api_upgraded-4.1.2.dist-info}/METADATA +5 -4
- bitvavo_api_upgraded-4.1.2.dist-info/RECORD +38 -0
- bitvavo_client/__init__.py +9 -0
- bitvavo_client/adapters/__init__.py +1 -0
- bitvavo_client/adapters/returns_adapter.py +362 -0
- bitvavo_client/auth/__init__.py +1 -0
- bitvavo_client/auth/rate_limit.py +104 -0
- bitvavo_client/auth/signing.py +33 -0
- bitvavo_client/core/__init__.py +1 -0
- bitvavo_client/core/errors.py +17 -0
- bitvavo_client/core/model_preferences.py +51 -0
- bitvavo_client/core/private_models.py +886 -0
- bitvavo_client/core/public_models.py +1087 -0
- bitvavo_client/core/settings.py +52 -0
- bitvavo_client/core/types.py +11 -0
- bitvavo_client/core/validation_helpers.py +90 -0
- bitvavo_client/df/__init__.py +1 -0
- bitvavo_client/df/convert.py +86 -0
- bitvavo_client/endpoints/__init__.py +1 -0
- bitvavo_client/endpoints/common.py +88 -0
- bitvavo_client/endpoints/private.py +1232 -0
- bitvavo_client/endpoints/public.py +748 -0
- bitvavo_client/facade.py +66 -0
- bitvavo_client/py.typed +0 -0
- bitvavo_client/schemas/__init__.py +50 -0
- bitvavo_client/schemas/private_schemas.py +191 -0
- bitvavo_client/schemas/public_schemas.py +149 -0
- bitvavo_client/transport/__init__.py +1 -0
- bitvavo_client/transport/http.py +159 -0
- bitvavo_client/ws/__init__.py +1 -0
- bitvavo_api_upgraded-4.1.0.dist-info/RECORD +0 -10
- {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]
|