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
bitvavo_api_upgraded/bitvavo.py
CHANGED
@@ -2,16 +2,16 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import contextlib
|
4
4
|
import datetime as dt
|
5
|
-
import hashlib
|
6
|
-
import hmac
|
7
5
|
import json
|
6
|
+
import statistics
|
8
7
|
import time
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
9
9
|
from pathlib import Path
|
10
10
|
from threading import Thread
|
11
|
-
from typing import
|
11
|
+
from typing import TYPE_CHECKING, Any
|
12
12
|
|
13
13
|
import websocket as ws_lib
|
14
|
-
from
|
14
|
+
from httpx import delete, get, post, put
|
15
15
|
from structlog.stdlib import get_logger
|
16
16
|
from websocket import WebSocketApp # missing stubs for WebSocketApp
|
17
17
|
|
@@ -19,88 +19,24 @@ from bitvavo_api_upgraded.dataframe_utils import convert_candles_to_dataframe, c
|
|
19
19
|
from bitvavo_api_upgraded.helper_funcs import configure_loggers, time_ms, time_to_wait
|
20
20
|
from bitvavo_api_upgraded.settings import bitvavo_settings, bitvavo_upgraded_settings
|
21
21
|
from bitvavo_api_upgraded.type_aliases import OutputFormat, anydict, errordict, intdict, ms, s_f, strdict, strintdict
|
22
|
+
from bitvavo_client.auth.signing import create_signature
|
23
|
+
from bitvavo_client.endpoints.common import (
|
24
|
+
asks_compare,
|
25
|
+
bids_compare,
|
26
|
+
create_postfix,
|
27
|
+
default,
|
28
|
+
epoch_millis,
|
29
|
+
sort_and_insert,
|
30
|
+
)
|
31
|
+
|
32
|
+
if TYPE_CHECKING:
|
33
|
+
from collections.abc import Callable
|
22
34
|
|
23
35
|
configure_loggers()
|
24
36
|
|
25
37
|
logger = get_logger(__name__)
|
26
38
|
|
27
39
|
|
28
|
-
def create_signature(timestamp: ms, method: str, url: str, body: anydict | None, api_secret: str) -> str:
|
29
|
-
string = f"{timestamp}{method}/v2{url}"
|
30
|
-
if body is not None and len(body.keys()) > 0:
|
31
|
-
string += json.dumps(body, separators=(",", ":"))
|
32
|
-
signature = hmac.new(api_secret.encode("utf-8"), string.encode("utf-8"), hashlib.sha256).hexdigest()
|
33
|
-
return signature
|
34
|
-
|
35
|
-
|
36
|
-
def create_postfix(options: anydict | None) -> str:
|
37
|
-
"""Generate a URL postfix, based on the `options` dict.
|
38
|
-
|
39
|
-
---
|
40
|
-
Args:
|
41
|
-
options (anydict): [description]
|
42
|
-
|
43
|
-
---
|
44
|
-
Returns:
|
45
|
-
str: [description]
|
46
|
-
"""
|
47
|
-
options = _default(options, {})
|
48
|
-
params = [f"{key}={options[key]}" for key in options]
|
49
|
-
postfix = "&".join(params) # intersperse
|
50
|
-
return f"?{postfix}" if len(options) > 0 else postfix
|
51
|
-
|
52
|
-
|
53
|
-
def _default(value: anydict | None, fallback: anydict) -> anydict:
|
54
|
-
"""
|
55
|
-
Note that is close, but not actually equal to:
|
56
|
-
|
57
|
-
`return value or fallback`
|
58
|
-
|
59
|
-
I checked this with a temporary hypothesis test.
|
60
|
-
|
61
|
-
This note is all you will get out of me.
|
62
|
-
"""
|
63
|
-
return value if value is not None else fallback
|
64
|
-
|
65
|
-
|
66
|
-
def _epoch_millis(dt: dt.datetime) -> int:
|
67
|
-
return int(dt.timestamp() * 1000)
|
68
|
-
|
69
|
-
|
70
|
-
def asks_compare(a: float, b: float) -> bool:
|
71
|
-
return a < b
|
72
|
-
|
73
|
-
|
74
|
-
def bids_compare(a: float, b: float) -> bool:
|
75
|
-
return a > b
|
76
|
-
|
77
|
-
|
78
|
-
def sort_and_insert(
|
79
|
-
asks_or_bids: list[list[str]],
|
80
|
-
update: list[list[str]],
|
81
|
-
compareFunc: Callable[[float, float], bool],
|
82
|
-
) -> list[list[str]] | errordict:
|
83
|
-
for updateEntry in update:
|
84
|
-
entrySet: bool = False
|
85
|
-
for j in range(len(asks_or_bids)):
|
86
|
-
bookItem = asks_or_bids[j]
|
87
|
-
if compareFunc(float(updateEntry[0]), float(bookItem[0])):
|
88
|
-
asks_or_bids.insert(j, updateEntry)
|
89
|
-
entrySet = True
|
90
|
-
break
|
91
|
-
if float(updateEntry[0]) == float(bookItem[0]):
|
92
|
-
if float(updateEntry[1]) > 0.0:
|
93
|
-
asks_or_bids[j] = updateEntry
|
94
|
-
entrySet = True
|
95
|
-
break
|
96
|
-
asks_or_bids.pop(j)
|
97
|
-
entrySet = True
|
98
|
-
break
|
99
|
-
if not entrySet:
|
100
|
-
asks_or_bids.append(updateEntry)
|
101
|
-
return asks_or_bids
|
102
|
-
|
103
|
-
|
104
40
|
def process_local_book(ws: Bitvavo.WebSocketAppFacade, message: anydict) -> None:
|
105
41
|
market: str = ""
|
106
42
|
if "action" in message:
|
@@ -412,7 +348,9 @@ class Bitvavo:
|
|
412
348
|
key_name = f"API_KEY_{key_index}" if key_index >= 0 else "KEYLESS"
|
413
349
|
|
414
350
|
logger.warning(
|
415
|
-
"rate-limit-reached",
|
351
|
+
"rate-limit-reached",
|
352
|
+
key_name=key_name,
|
353
|
+
rateLimitRemaining=self.rate_limits[key_index]["remaining"],
|
416
354
|
)
|
417
355
|
logger.info(
|
418
356
|
"napping-until-reset",
|
@@ -423,27 +361,108 @@ class Bitvavo:
|
|
423
361
|
)
|
424
362
|
time.sleep(napTime + 1) # +1 to add a tiny bit of buffer time
|
425
363
|
|
426
|
-
def calc_lag(self) -> ms:
|
364
|
+
def calc_lag(self, samples: int = 5, timeout_seconds: float = 5.0) -> ms: # noqa: C901
|
427
365
|
"""
|
428
|
-
Calculate the time difference between the client and server
|
429
|
-
|
366
|
+
Calculate the time difference between the client and server using statistical analysis.
|
367
|
+
|
368
|
+
Uses multiple samples with outlier detection to get a more accurate lag measurement.
|
369
|
+
|
370
|
+
Args:
|
371
|
+
samples: Number of time samples to collect (default: 5)
|
372
|
+
timeout_seconds: Maximum time to spend collecting samples (default: 5.0)
|
373
|
+
|
374
|
+
Returns:
|
375
|
+
Average lag in milliseconds
|
430
376
|
|
431
|
-
Raises
|
377
|
+
Raises:
|
378
|
+
ValueError: If unable to collect sufficient valid samples
|
379
|
+
RuntimeError: If all API calls fail
|
432
380
|
"""
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
381
|
+
ARBITRARY = 3
|
382
|
+
if samples < ARBITRARY:
|
383
|
+
msg = f"Need at least {ARBITRARY} samples for statistical analysis"
|
384
|
+
raise ValueError(msg)
|
385
|
+
|
386
|
+
def measure_single_lag() -> ms | None:
|
387
|
+
"""Measure lag for a single request with error handling."""
|
388
|
+
try:
|
389
|
+
client_time_before = time_ms()
|
390
|
+
server_response = self.time()
|
391
|
+
client_time_after = time_ms()
|
392
|
+
|
393
|
+
if isinstance(server_response, dict) and "time" in server_response:
|
394
|
+
# Use midpoint of request duration for better accuracy
|
395
|
+
client_time_avg = (client_time_before + client_time_after) // 2
|
396
|
+
server_time = server_response["time"]
|
397
|
+
if isinstance(server_time, int):
|
398
|
+
return ms(server_time - client_time_avg)
|
399
|
+
return None
|
400
|
+
except (ValueError, TypeError, KeyError):
|
401
|
+
return None
|
402
|
+
else:
|
403
|
+
# If error or unexpected response
|
404
|
+
return None
|
405
|
+
|
406
|
+
lag_measurements: list[ms] = []
|
407
|
+
|
408
|
+
# Collect samples concurrently for better performance
|
409
|
+
with ThreadPoolExecutor(max_workers=min(samples, 5)) as executor:
|
410
|
+
try:
|
411
|
+
# Submit all measurement tasks
|
412
|
+
future_to_sample = {executor.submit(measure_single_lag): i for i in range(samples)}
|
413
|
+
|
414
|
+
# Collect results with timeout
|
415
|
+
for future in as_completed(future_to_sample, timeout=timeout_seconds):
|
416
|
+
lag = future.result()
|
417
|
+
if lag is not None:
|
418
|
+
lag_measurements.append(lag)
|
419
|
+
|
420
|
+
except TimeoutError:
|
421
|
+
if self.debugging:
|
422
|
+
logger.warning(
|
423
|
+
"lag-calculation-timeout",
|
424
|
+
collected_samples=len(lag_measurements),
|
425
|
+
requested_samples=samples,
|
426
|
+
)
|
427
|
+
|
428
|
+
if len(lag_measurements) < max(2, samples // 2):
|
429
|
+
msg = f"Insufficient valid samples: got {len(lag_measurements)}, need at least {max(2, samples // 2)}"
|
430
|
+
raise RuntimeError(msg)
|
431
|
+
|
432
|
+
# Remove outliers using interquartile range method
|
433
|
+
QUARTILES = 4
|
434
|
+
if len(lag_measurements) >= QUARTILES:
|
435
|
+
try:
|
436
|
+
q1 = statistics.quantiles(lag_measurements, n=QUARTILES)[0]
|
437
|
+
q3 = statistics.quantiles(lag_measurements, n=QUARTILES)[2]
|
438
|
+
iqr = q3 - q1
|
439
|
+
lower_bound = q1 - 1.5 * iqr
|
440
|
+
upper_bound = q3 + 1.5 * iqr
|
441
|
+
|
442
|
+
filtered_measurements = [lag for lag in lag_measurements if lower_bound <= lag <= upper_bound]
|
443
|
+
|
444
|
+
# Use filtered data if we still have enough samples
|
445
|
+
if len(filtered_measurements) >= 2: # noqa: PLR2004
|
446
|
+
lag_measurements = filtered_measurements
|
447
|
+
|
448
|
+
except statistics.StatisticsError:
|
449
|
+
# Fall back to original measurements if filtering fails
|
450
|
+
pass
|
451
|
+
|
452
|
+
# Calculate final lag using median for robustness
|
453
|
+
final_lag = ms(statistics.median(lag_measurements))
|
454
|
+
|
455
|
+
if self.debugging:
|
456
|
+
logger.debug(
|
457
|
+
"lag-calculated",
|
458
|
+
samples_collected=len(lag_measurements),
|
459
|
+
lag_ms=final_lag,
|
460
|
+
min_lag=min(lag_measurements),
|
461
|
+
max_lag=max(lag_measurements),
|
462
|
+
std_dev=statistics.stdev(lag_measurements) if len(lag_measurements) > 1 else 0,
|
463
|
+
)
|
445
464
|
|
446
|
-
return
|
465
|
+
return final_lag
|
447
466
|
|
448
467
|
def get_remaining_limit(self) -> int:
|
449
468
|
"""Get the remaining rate limit
|
@@ -576,7 +595,6 @@ class Bitvavo:
|
|
576
595
|
|
577
596
|
---
|
578
597
|
Args:
|
579
|
-
# TODO(NostraDavid) fill these in
|
580
598
|
```python
|
581
599
|
endpoint: str = "/order"
|
582
600
|
postfix: str = "" # ?key=value&key2=another_value&...
|
@@ -1045,14 +1063,14 @@ class Bitvavo:
|
|
1045
1063
|
# timestamp is converted to datetime, numeric columns to float
|
1046
1064
|
```
|
1047
1065
|
"""
|
1048
|
-
options =
|
1066
|
+
options = default(options, {})
|
1049
1067
|
options["interval"] = interval
|
1050
1068
|
if limit is not None:
|
1051
1069
|
options["limit"] = limit
|
1052
1070
|
if start is not None:
|
1053
|
-
options["start"] =
|
1071
|
+
options["start"] = epoch_millis(start)
|
1054
1072
|
if end is not None:
|
1055
|
-
options["end"] =
|
1073
|
+
options["end"] = epoch_millis(end)
|
1056
1074
|
postfix = create_postfix(options)
|
1057
1075
|
result = self.public_request(f"{self.base}/{market}/candles{postfix}") # type: ignore[return-value]
|
1058
1076
|
return convert_candles_to_dataframe(result, output_format)
|
@@ -1279,7 +1297,7 @@ class Bitvavo:
|
|
1279
1297
|
]
|
1280
1298
|
```
|
1281
1299
|
"""
|
1282
|
-
options =
|
1300
|
+
options = default(options, {})
|
1283
1301
|
rateLimitingWeight = 25
|
1284
1302
|
if "market" in options:
|
1285
1303
|
rateLimitingWeight = 1
|
@@ -1796,7 +1814,7 @@ class Bitvavo:
|
|
1796
1814
|
]
|
1797
1815
|
```
|
1798
1816
|
""" # noqa: E501
|
1799
|
-
options =
|
1817
|
+
options = default(options, {})
|
1800
1818
|
options["market"] = market
|
1801
1819
|
postfix = create_postfix(options)
|
1802
1820
|
return self.private_request("/orders", postfix, {}, "GET", 5) # type: ignore[return-value]
|
@@ -1894,7 +1912,7 @@ class Bitvavo:
|
|
1894
1912
|
]
|
1895
1913
|
```
|
1896
1914
|
"""
|
1897
|
-
options =
|
1915
|
+
options = default(options, {})
|
1898
1916
|
rateLimitingWeight = 25
|
1899
1917
|
if "market" in options:
|
1900
1918
|
rateLimitingWeight = 1
|
@@ -1960,7 +1978,7 @@ class Bitvavo:
|
|
1960
1978
|
]
|
1961
1979
|
```
|
1962
1980
|
""" # noqa: E501
|
1963
|
-
options =
|
1981
|
+
options = default(options, {})
|
1964
1982
|
options["market"] = market
|
1965
1983
|
postfix = create_postfix(options)
|
1966
1984
|
result = self.private_request("/trades", postfix, {}, "GET", 5) # type: ignore[return-value]
|
@@ -2489,7 +2507,6 @@ class Bitvavo:
|
|
2489
2507
|
time.sleep(0.1)
|
2490
2508
|
|
2491
2509
|
def do_send(self, ws: WebSocketApp, message: str, private: bool = False) -> None: # noqa: FBT001, FBT002
|
2492
|
-
# TODO(NostraDavid): add nap-time to the websocket, or do it here; I don't know yet.
|
2493
2510
|
if private and self.APIKEY == "":
|
2494
2511
|
logger.error(
|
2495
2512
|
"no-apikey",
|
@@ -3961,8 +3978,6 @@ class Bitvavo:
|
|
3961
3978
|
self.do_send(self.ws, json.dumps(options), True)
|
3962
3979
|
|
3963
3980
|
def subscription_ticker(self, market: str, callback: Callable[[Any], None]) -> None:
|
3964
|
-
# TODO(NostraDavid): one possible improvement here is to turn `market` into a list of markets, so we can sub
|
3965
|
-
# to all of them at once. Same goes for other `subscription*()`
|
3966
3981
|
"""
|
3967
3982
|
Subscribe to the ticker channel, which means `callback` gets passed the new best bid or ask whenever they
|
3968
3983
|
change (server-side).
|
@@ -165,7 +165,9 @@ def convert_candles_to_dataframe(data: Any, output_format: str | OutputFormat) -
|
|
165
165
|
# Convert list of lists to list of dicts first
|
166
166
|
columns = ["timestamp", "open", "high", "low", "close", "volume"]
|
167
167
|
dict_data = [
|
168
|
-
dict(zip(columns, candle
|
168
|
+
dict(zip(columns, candle, strict=True))
|
169
|
+
for candle in data
|
170
|
+
if isinstance(candle, list) and len(candle) >= len(columns)
|
169
171
|
]
|
170
172
|
|
171
173
|
if not dict_data:
|
bitvavo_api_upgraded/settings.py
CHANGED
@@ -105,7 +105,7 @@ class BitvavoSettings(BaseSettings):
|
|
105
105
|
PREFER_KEYLESS: bool = Field(default=True)
|
106
106
|
|
107
107
|
# Configuration for Pydantic Settings
|
108
|
-
model_config
|
108
|
+
model_config = SettingsConfigDict(
|
109
109
|
env_file=Path.cwd() / ".env",
|
110
110
|
env_file_encoding="utf-8",
|
111
111
|
env_prefix="BITVAVO_",
|
@@ -4,7 +4,7 @@ to clearify the intention or semantics/meaning/unit of a variable
|
|
4
4
|
"""
|
5
5
|
|
6
6
|
import sys
|
7
|
-
from typing import Any
|
7
|
+
from typing import Any
|
8
8
|
|
9
9
|
if sys.version_info >= (3, 11):
|
10
10
|
from enum import StrEnum
|
@@ -26,7 +26,7 @@ anydict = dict[str, Any]
|
|
26
26
|
strdict = dict[str, str]
|
27
27
|
intdict = dict[str, int]
|
28
28
|
# can't use | here, with __future__. Not sure why.
|
29
|
-
strintdict = dict[str,
|
29
|
+
strintdict = dict[str, str | int]
|
30
30
|
errordict = dict[str, Any] # same type as anydict, but the semantics/meaning is different
|
31
31
|
|
32
32
|
# note: You can also use these for type conversion, so instead of int(some_float / 1000), you can just do ms(some_float
|