tardis-dev 3.0.0__tar.gz → 3.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tardis-dev
3
- Version: 3.0.0
3
+ Version: 3.2.0
4
4
  Summary: Python client for tardis.dev - historical tick-level cryptocurrency market data replay API.
5
5
  Home-page: https://github.com/tardis-dev/tardis-python
6
6
  License: MPL-2.0
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
19
19
  Requires-Dist: aiohttp (>=3.10.0)
20
20
  Requires-Dist: python-dateutil (>=2.8.2,<3.0.0)
21
+ Requires-Dist: zstandard (>=0.19.0)
21
22
  Description-Content-Type: text/markdown
22
23
 
23
24
  # tardis-dev
@@ -28,7 +29,9 @@ Description-Content-Type: text/markdown
28
29
 
29
30
  <br/>
30
31
 
31
- Python `tardis-dev` package provides convenient access to tick-level historical cryptocurrency market data in exchange-native format. It focuses on two primary workflows: replaying historical market data and downloading CSV datasets. If you need normalized replay or real-time streaming, use the Node.js client or Tardis Machine.
32
+ Python `tardis-dev` package provides convenient access to tick-level historical cryptocurrency market data in exchange-native format. It focuses on two primary workflows: replaying historical market data and downloading historical market data as CSV files. If you need normalized replay or real-time streaming, use the Node.js client or Tardis Machine.
33
+
34
+ `replay()` accepts ISO date strings or Python `datetime` values. Naive datetimes are treated as UTC.
32
35
 
33
36
  <br/>
34
37
 
@@ -59,7 +62,7 @@ asyncio.run(main())
59
62
 
60
63
  <br/>
61
64
 
62
- - [CSV dataset downloads](https://docs.tardis.dev/downloadable-csv-files/overview)
65
+ - [historical market data downloads as CSV files](https://docs.tardis.dev/python-client/quickstart#csv-dataset-downloads)
63
66
 
64
67
  <br/>
65
68
 
@@ -86,7 +89,7 @@ pip install tardis-dev
86
89
 
87
90
  - [Quickstart](https://docs.tardis.dev/python-client/quickstart)
88
91
  - [Replaying Historical Data](https://docs.tardis.dev/python-client/replaying-historical-data)
89
- - [Downloadable CSV files](https://docs.tardis.dev/downloadable-csv-files/overview)
92
+ - [CSV Dataset Downloads](https://docs.tardis.dev/python-client/quickstart#csv-dataset-downloads)
90
93
  - [Migration Notice](https://docs.tardis.dev/python-client/migration-notice)
91
94
 
92
95
  <br/>
@@ -6,7 +6,9 @@
6
6
 
7
7
  <br/>
8
8
 
9
- Python `tardis-dev` package provides convenient access to tick-level historical cryptocurrency market data in exchange-native format. It focuses on two primary workflows: replaying historical market data and downloading CSV datasets. If you need normalized replay or real-time streaming, use the Node.js client or Tardis Machine.
9
+ Python `tardis-dev` package provides convenient access to tick-level historical cryptocurrency market data in exchange-native format. It focuses on two primary workflows: replaying historical market data and downloading historical market data as CSV files. If you need normalized replay or real-time streaming, use the Node.js client or Tardis Machine.
10
+
11
+ `replay()` accepts ISO date strings or Python `datetime` values. Naive datetimes are treated as UTC.
10
12
 
11
13
  <br/>
12
14
 
@@ -37,7 +39,7 @@ asyncio.run(main())
37
39
 
38
40
  <br/>
39
41
 
40
- - [CSV dataset downloads](https://docs.tardis.dev/downloadable-csv-files/overview)
42
+ - [historical market data downloads as CSV files](https://docs.tardis.dev/python-client/quickstart#csv-dataset-downloads)
41
43
 
42
44
  <br/>
43
45
 
@@ -64,7 +66,7 @@ pip install tardis-dev
64
66
 
65
67
  - [Quickstart](https://docs.tardis.dev/python-client/quickstart)
66
68
  - [Replaying Historical Data](https://docs.tardis.dev/python-client/replaying-historical-data)
67
- - [Downloadable CSV files](https://docs.tardis.dev/downloadable-csv-files/overview)
69
+ - [CSV Dataset Downloads](https://docs.tardis.dev/python-client/quickstart#csv-dataset-downloads)
68
70
  - [Migration Notice](https://docs.tardis.dev/python-client/migration-notice)
69
71
 
70
72
  <br/>
@@ -9,7 +9,7 @@ markers = [
9
9
 
10
10
  [tool.poetry]
11
11
  name = "tardis-dev"
12
- version = "3.0.0"
12
+ version = "3.2.0"
13
13
  description = "Python client for tardis.dev - historical tick-level cryptocurrency market data replay API."
14
14
  readme = "README.md"
15
15
  homepage = "https://github.com/tardis-dev/tardis-python"
@@ -30,6 +30,7 @@ python = ">=3.9,<4.0"
30
30
  aiohttp = ">=3.10.0"
31
31
  python-dateutil = "^2.8.2"
32
32
  aiofiles = "^24.1.0"
33
+ zstandard = ">=0.19.0"
33
34
 
34
35
  [tool.poetry.group.dev.dependencies]
35
36
  pytest = "^8.3.5"
@@ -14,11 +14,11 @@ import aiohttp
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
16
 
17
- async def create_session(api_key: str, timeout: int) -> aiohttp.ClientSession:
17
+ async def create_session(api_key: str, timeout: int, accept_encoding: str = "gzip") -> aiohttp.ClientSession:
18
18
  from tardis_dev import __version__
19
19
 
20
20
  headers = {
21
- "Accept-Encoding": "gzip",
21
+ "Accept-Encoding": accept_encoding,
22
22
  "User-Agent": f"tardis-dev/{__version__} (+https://github.com/tardis-dev/tardis-python)",
23
23
  }
24
24
  if api_key:
@@ -38,14 +38,20 @@ async def reliable_download(
38
38
  dest_path: str,
39
39
  http_proxy: Optional[str] = None,
40
40
  max_attempts: int = 30,
41
- ) -> None:
41
+ append_content_encoding_extension: bool = False,
42
+ ) -> str:
42
43
  attempts = 0
43
44
 
44
45
  while True:
45
46
  attempts += 1
46
47
  try:
47
- await _download(session, _get_retry_url(url, attempts), dest_path, http_proxy)
48
- return
48
+ return await _download(
49
+ session,
50
+ _get_retry_url(url, attempts),
51
+ dest_path,
52
+ http_proxy,
53
+ append_content_encoding_extension=append_content_encoding_extension,
54
+ )
49
55
  except asyncio.CancelledError:
50
56
  raise
51
57
  except Exception as exc:
@@ -99,12 +105,30 @@ async def _download(
99
105
  url: str,
100
106
  dest_path: str,
101
107
  http_proxy: Optional[str],
102
- ) -> None:
108
+ *,
109
+ append_content_encoding_extension: bool,
110
+ ) -> str:
103
111
  async with session.get(url, proxy=http_proxy) as response:
104
112
  if response.status != 200:
105
113
  error_text = await response.text()
106
114
  raise urllib.error.HTTPError(url, code=response.status, msg=error_text, hdrs=None, fp=None)
107
115
 
116
+ final_path = dest_path
117
+ if append_content_encoding_extension:
118
+ content_encoding = response.headers.get("Content-Encoding")
119
+ if content_encoding == "zstd":
120
+ final_path = f"{dest_path}.zst"
121
+ elif content_encoding == "gzip":
122
+ final_path = f"{dest_path}.gz"
123
+ else:
124
+ raise urllib.error.HTTPError(
125
+ url,
126
+ code=400,
127
+ msg=f"Unsupported data feed content encoding: {content_encoding}",
128
+ hdrs=None,
129
+ fp=None,
130
+ )
131
+
108
132
  pathlib.Path(dest_path).parent.mkdir(parents=True, exist_ok=True)
109
133
  temp_path = f"{dest_path}{secrets.token_hex(8)}.unconfirmed"
110
134
 
@@ -113,7 +137,8 @@ async def _download(
113
137
  async for chunk in response.content.iter_any():
114
138
  await temp_file.write(chunk)
115
139
 
116
- os.replace(temp_path, dest_path)
140
+ os.replace(temp_path, final_path)
141
+ return final_path
117
142
  finally:
118
143
  if os.path.exists(temp_path):
119
144
  os.remove(temp_path)
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import gzip
3
3
  import hashlib
4
+ import io
4
5
  import json as json_module
5
6
  import logging
6
7
  import os
@@ -11,6 +12,7 @@ from time import time
11
12
  from typing import Any, AsyncIterator, Dict, List, NamedTuple, Optional, Sequence, Union
12
13
 
13
14
  import dateutil.parser
15
+ import zstandard
14
16
 
15
17
  from tardis_dev._http import create_session, reliable_download
16
18
  from tardis_dev._options import DEFAULT_CACHE_DIR, DEFAULT_ENDPOINT
@@ -29,8 +31,8 @@ class Response(NamedTuple):
29
31
 
30
32
  async def replay(
31
33
  exchange: str,
32
- from_date: str,
33
- to_date: str,
34
+ from_date: Union[str, datetime],
35
+ to_date: Union[str, datetime],
34
36
  filters: Optional[Sequence[Channel]] = None,
35
37
  *,
36
38
  api_key: str = "",
@@ -84,7 +86,15 @@ async def replay(
84
86
 
85
87
  while current_slice_path is None:
86
88
  await asyncio.sleep(0)
87
- path_to_check = _get_slice_cache_path(
89
+ zstd_path = _get_slice_cache_path(
90
+ cache_dir,
91
+ exchange,
92
+ current_slice_date,
93
+ normalized_filters,
94
+ filters_hash=filters_hash,
95
+ content_encoding="zstd",
96
+ )
97
+ gzip_path = _get_slice_cache_path(
88
98
  cache_dir,
89
99
  exchange,
90
100
  current_slice_date,
@@ -95,26 +105,27 @@ async def replay(
95
105
  if fetch_data_task.done() and fetch_data_task.exception():
96
106
  raise fetch_data_task.exception()
97
107
 
98
- if os.path.isfile(path_to_check):
99
- current_slice_path = path_to_check
108
+ if os.path.isfile(zstd_path):
109
+ current_slice_path = zstd_path
110
+ elif os.path.isfile(gzip_path):
111
+ current_slice_path = gzip_path
100
112
  else:
101
113
  await asyncio.sleep(0.1)
102
114
 
103
- with gzip.open(current_slice_path, "rb") as file:
104
- for line in file:
105
- if len(line) <= 1:
106
- if with_disconnects and not last_message_was_disconnect:
107
- last_message_was_disconnect = True
108
- yield None
109
- continue
115
+ for line in _iterate_slice_lines(current_slice_path):
116
+ if len(line) <= 1:
117
+ if with_disconnects and not last_message_was_disconnect:
118
+ last_message_was_disconnect = True
119
+ yield None
120
+ continue
110
121
 
111
- last_message_was_disconnect = False
122
+ last_message_was_disconnect = False
112
123
 
113
- if decode_response:
114
- timestamp = datetime.fromisoformat(line[0 : DATE_MESSAGE_SPLIT_INDEX - 2].decode("utf-8"))
115
- yield Response(timestamp, json.loads(line[DATE_MESSAGE_SPLIT_INDEX + 1 :]))
116
- else:
117
- yield Response(line[0:DATE_MESSAGE_SPLIT_INDEX], line[DATE_MESSAGE_SPLIT_INDEX + 1 :])
124
+ if decode_response:
125
+ timestamp = datetime.fromisoformat(line[0 : DATE_MESSAGE_SPLIT_INDEX - 2].decode("utf-8"))
126
+ yield Response(timestamp, json.loads(line[DATE_MESSAGE_SPLIT_INDEX + 1 :]))
127
+ else:
128
+ yield Response(line[0:DATE_MESSAGE_SPLIT_INDEX], line[DATE_MESSAGE_SPLIT_INDEX + 1 :])
118
129
 
119
130
  if auto_cleanup:
120
131
  _remove_processed_slice(current_slice_path)
@@ -167,7 +178,7 @@ async def _fetch_data_to_replay(
167
178
  if minutes_diff <= 0:
168
179
  return
169
180
 
170
- async with await create_session(api_key, timeout) as session:
181
+ async with await create_session(api_key, timeout, "zstd, gzip") as session:
171
182
  fetch_data_tasks = set()
172
183
  try:
173
184
  prefetch_offsets = [minutes_diff - 1]
@@ -231,9 +242,23 @@ async def _fetch_slice_if_not_cached(
231
242
  filters_hash: str,
232
243
  ) -> None:
233
244
  slice_date = from_date + timedelta(minutes=offset)
234
- cache_path = _get_slice_cache_path(cache_dir, exchange, slice_date, filters, filters_hash=filters_hash)
245
+ cache_zstd_path = _get_slice_cache_path(
246
+ cache_dir,
247
+ exchange,
248
+ slice_date,
249
+ filters,
250
+ filters_hash=filters_hash,
251
+ content_encoding="zstd",
252
+ )
253
+ cache_gzip_path = _get_slice_cache_path(
254
+ cache_dir,
255
+ exchange,
256
+ slice_date,
257
+ filters,
258
+ filters_hash=filters_hash,
259
+ )
235
260
 
236
- if os.path.isfile(cache_path):
261
+ if os.path.isfile(cache_zstd_path) or os.path.isfile(cache_gzip_path):
237
262
  return
238
263
 
239
264
  fetch_url = f"{endpoint}/data-feeds/{exchange}?from={_format_replay_query_date(from_date)}&offset={offset}"
@@ -242,11 +267,14 @@ async def _fetch_slice_if_not_cached(
242
267
  filters_url_encoded = urllib.parse.quote(filters_serialized, safe="~()*!.'")
243
268
  fetch_url += f"&filters={filters_url_encoded}"
244
269
 
270
+ cache_base_path = cache_gzip_path.removesuffix(".gz")
271
+
245
272
  await reliable_download(
246
273
  session=session,
247
274
  url=fetch_url,
248
- dest_path=cache_path,
275
+ dest_path=cache_base_path,
249
276
  http_proxy=http_proxy,
277
+ append_content_encoding_extension=True,
250
278
  )
251
279
 
252
280
 
@@ -310,20 +338,28 @@ def _validate_replay_args(
310
338
  raise ValueError(f"Invalid 'symbols[]' argument: {channel.symbols}. Please provide list of symbol strings.")
311
339
 
312
340
 
313
- def _parse_date(name: str, value: str) -> datetime:
341
+ def _parse_date(name: str, value: Union[str, datetime]) -> datetime:
314
342
  if value is None:
315
343
  raise ValueError(
316
344
  f"Invalid '{name}' argument: {value}. Please provide valid ISO date string. "
317
345
  "https://docs.python.org/3/library/datetime.html#datetime.date.fromisoformat"
318
346
  )
319
347
 
320
- try:
321
- return dateutil.parser.isoparse(value)
322
- except (TypeError, ValueError) as exc:
323
- raise ValueError(
324
- f"Invalid '{name}' argument: {value}. Please provide valid ISO date string. "
325
- "https://docs.python.org/3/library/datetime.html#datetime.date.fromisoformat"
326
- ) from exc
348
+ if isinstance(value, datetime):
349
+ parsed = value
350
+ else:
351
+ try:
352
+ parsed = dateutil.parser.isoparse(value)
353
+ except (TypeError, ValueError) as exc:
354
+ raise ValueError(
355
+ f"Invalid '{name}' argument: {value}. Please provide valid ISO date string. "
356
+ "https://docs.python.org/3/library/datetime.html#datetime.date.fromisoformat"
357
+ ) from exc
358
+
359
+ if parsed.tzinfo is None:
360
+ return parsed.replace(tzinfo=timezone.utc)
361
+
362
+ return parsed.astimezone(timezone.utc)
327
363
 
328
364
 
329
365
  def _get_slice_cache_path(
@@ -333,16 +369,14 @@ def _get_slice_cache_path(
333
369
  filters: Optional[Sequence[Channel]],
334
370
  *,
335
371
  filters_hash: Optional[str] = None,
372
+ content_encoding: Optional[str] = None,
336
373
  ) -> str:
337
- return (
338
- os.path.join(
339
- cache_dir,
340
- "feeds",
341
- exchange,
342
- filters_hash if filters_hash is not None else _get_filters_hash(filters),
343
- _format_date_to_path(date),
344
- )
345
- + ".json.gz"
374
+ return os.path.join(
375
+ cache_dir,
376
+ "feeds",
377
+ exchange,
378
+ filters_hash if filters_hash is not None else _get_filters_hash(filters),
379
+ f"{_format_date_to_path(date)}.json{'.zst' if content_encoding == 'zstd' else '.gz'}",
346
380
  )
347
381
 
348
382
 
@@ -397,6 +431,18 @@ def _remove_processed_slice(path: str) -> None:
397
431
  os.remove(path)
398
432
 
399
433
 
434
+ def _iterate_slice_lines(path: str):
435
+ if path.endswith(".zst"):
436
+ with open(path, "rb") as compressed_file:
437
+ with zstandard.ZstdDecompressor().stream_reader(compressed_file) as file:
438
+ with io.BufferedReader(file) as buffered_file:
439
+ yield from buffered_file
440
+ return
441
+
442
+ with gzip.open(path, "rb") as file:
443
+ yield from file
444
+
445
+
400
446
  def _clear_replay_cache_range(
401
447
  *,
402
448
  cache_dir: str,
File without changes