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.
Files changed (36) hide show
  1. bitvavo_api_upgraded/bitvavo.py +124 -109
  2. bitvavo_api_upgraded/dataframe_utils.py +3 -1
  3. bitvavo_api_upgraded/settings.py +1 -1
  4. bitvavo_api_upgraded/type_aliases.py +2 -2
  5. {bitvavo_api_upgraded-4.0.0.dist-info → bitvavo_api_upgraded-4.1.1.dist-info}/METADATA +404 -84
  6. bitvavo_api_upgraded-4.1.1.dist-info/RECORD +38 -0
  7. {bitvavo_api_upgraded-4.0.0.dist-info → bitvavo_api_upgraded-4.1.1.dist-info}/WHEEL +1 -1
  8. bitvavo_client/__init__.py +9 -0
  9. bitvavo_client/adapters/__init__.py +1 -0
  10. bitvavo_client/adapters/returns_adapter.py +363 -0
  11. bitvavo_client/auth/__init__.py +1 -0
  12. bitvavo_client/auth/rate_limit.py +104 -0
  13. bitvavo_client/auth/signing.py +33 -0
  14. bitvavo_client/core/__init__.py +1 -0
  15. bitvavo_client/core/errors.py +17 -0
  16. bitvavo_client/core/model_preferences.py +33 -0
  17. bitvavo_client/core/private_models.py +886 -0
  18. bitvavo_client/core/public_models.py +1087 -0
  19. bitvavo_client/core/settings.py +52 -0
  20. bitvavo_client/core/types.py +11 -0
  21. bitvavo_client/core/validation_helpers.py +90 -0
  22. bitvavo_client/df/__init__.py +1 -0
  23. bitvavo_client/df/convert.py +86 -0
  24. bitvavo_client/endpoints/__init__.py +1 -0
  25. bitvavo_client/endpoints/common.py +88 -0
  26. bitvavo_client/endpoints/private.py +1090 -0
  27. bitvavo_client/endpoints/public.py +658 -0
  28. bitvavo_client/facade.py +66 -0
  29. bitvavo_client/py.typed +0 -0
  30. bitvavo_client/schemas/__init__.py +50 -0
  31. bitvavo_client/schemas/private_schemas.py +191 -0
  32. bitvavo_client/schemas/public_schemas.py +149 -0
  33. bitvavo_client/transport/__init__.py +1 -0
  34. bitvavo_client/transport/http.py +159 -0
  35. bitvavo_client/ws/__init__.py +1 -0
  36. bitvavo_api_upgraded-4.0.0.dist-info/RECORD +0 -10
@@ -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 Any, Callable
11
+ from typing import TYPE_CHECKING, Any
12
12
 
13
13
  import websocket as ws_lib
14
- from requests import delete, get, post, put
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", key_name=key_name, rateLimitRemaining=self.rate_limits[key_index]["remaining"]
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; use this value with BITVAVO_API_UPGRADED_LAG,
429
- when you make an api call, to precent 304 errors.
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 KeyError if time() returns an error dict.
377
+ Raises:
378
+ ValueError: If unable to collect sufficient valid samples
379
+ RuntimeError: If all API calls fail
432
380
  """
433
- lag_list = [
434
- self.time()["time"] - time_ms(),
435
- self.time()["time"] - time_ms(),
436
- self.time()["time"] - time_ms(),
437
- self.time()["time"] - time_ms(),
438
- self.time()["time"] - time_ms(),
439
- self.time()["time"] - time_ms(),
440
- self.time()["time"] - time_ms(),
441
- self.time()["time"] - time_ms(),
442
- self.time()["time"] - time_ms(),
443
- self.time()["time"] - time_ms(),
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 ms(sum(lag_list) / len(lag_list))
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 = _default(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"] = _epoch_millis(start)
1071
+ options["start"] = epoch_millis(start)
1054
1072
  if end is not None:
1055
- options["end"] = _epoch_millis(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 = _default(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 = _default(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 = _default(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 = _default(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)) for candle in data if isinstance(candle, list) and len(candle) >= len(columns)
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:
@@ -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: SettingsConfigDict = SettingsConfigDict(
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, Union
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, Union[str, int]]
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