bitvavo-api-upgraded 4.0.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.
- bitvavo_api_upgraded/bitvavo.py +124 -109
- bitvavo_api_upgraded/dataframe_utils.py +3 -1
- bitvavo_api_upgraded/settings.py +1 -1
- bitvavo_api_upgraded/type_aliases.py +2 -2
- {bitvavo_api_upgraded-4.0.0.dist-info → bitvavo_api_upgraded-4.1.1.dist-info}/METADATA +404 -84
- bitvavo_api_upgraded-4.1.1.dist-info/RECORD +38 -0
- {bitvavo_api_upgraded-4.0.0.dist-info → bitvavo_api_upgraded-4.1.1.dist-info}/WHEEL +1 -1
- bitvavo_client/__init__.py +9 -0
- bitvavo_client/adapters/__init__.py +1 -0
- bitvavo_client/adapters/returns_adapter.py +363 -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 +33 -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 +1090 -0
- bitvavo_client/endpoints/public.py +658 -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.0.0.dist-info/RECORD +0 -10
@@ -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
|
+
]
|