deeptick 0.1.0__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.
deeptick/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """DeepTick Python SDK."""
2
+
3
+ from .client import CachedClient, DeepTickAPIError, DeepTickClient
4
+
5
+ __all__ = ["CachedClient", "DeepTickAPIError", "DeepTickClient"]
6
+
deeptick/client.py ADDED
@@ -0,0 +1,450 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import io
5
+ import os
6
+ import shutil
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from datetime import date, datetime, timedelta
9
+ from pathlib import Path
10
+ from typing import Any, Iterable
11
+ from urllib.parse import quote
12
+
13
+ import pyarrow as pa
14
+ import pyarrow.parquet as pq
15
+ import requests
16
+
17
+ DEFAULT_BASE_URL = "https://deeptick.dev"
18
+ USER_AGENT = "deeptick-python/0.1.0"
19
+ DEFAULT_CACHE_DIR = Path(os.path.expanduser("~/.deeptick/cache"))
20
+ DateLike = str | date | datetime
21
+
22
+
23
+ class DeepTickAPIError(RuntimeError):
24
+ """Raised when the DeepTick API returns an unexpected HTTP error."""
25
+
26
+
27
+ def _normalize_base_url(base_url: str | None) -> str:
28
+ value = (base_url or DEFAULT_BASE_URL).rstrip("/")
29
+ if value.endswith("/v1"):
30
+ value = value[:-3]
31
+ return value.rstrip("/")
32
+
33
+
34
+ def _looks_like_url(value: str | None) -> bool:
35
+ return bool(value and value.startswith(("http://", "https://")))
36
+
37
+
38
+ def _resolve_cached_client_args(
39
+ api_key: str | None,
40
+ base_url: str,
41
+ ) -> tuple[str | None, str]:
42
+ if _looks_like_url(api_key):
43
+ if base_url == DEFAULT_BASE_URL:
44
+ return None, api_key or DEFAULT_BASE_URL
45
+ return base_url, api_key or DEFAULT_BASE_URL
46
+ return api_key, base_url
47
+
48
+
49
+ def _coerce_date(value: DateLike) -> date:
50
+ if isinstance(value, datetime):
51
+ return value.date()
52
+ if isinstance(value, date):
53
+ return value
54
+ try:
55
+ return datetime.strptime(value, "%Y-%m-%d").date()
56
+ except ValueError as exc:
57
+ raise ValueError(f"expected date as YYYY-MM-DD, got {value!r}") from exc
58
+
59
+
60
+ def _date_range(start: DateLike, end: DateLike | None) -> list[str]:
61
+ first = _coerce_date(start)
62
+ last = _coerce_date(end or start)
63
+ if last < first:
64
+ raise ValueError("end date must be on or after start date")
65
+
66
+ out: list[str] = []
67
+ current = first
68
+ while current <= last:
69
+ out.append(current.isoformat())
70
+ current += timedelta(days=1)
71
+ return out
72
+
73
+
74
+ def _columns_param(columns: Iterable[str] | None) -> str | None:
75
+ if columns is None:
76
+ return None
77
+ selected = [col for col in columns if col]
78
+ return ",".join(selected) if selected else None
79
+
80
+
81
+ def _empty_table() -> pa.Table:
82
+ return pa.table({})
83
+
84
+
85
+ def _resolve_date_args(
86
+ start: DateLike | None,
87
+ end: DateLike | None,
88
+ *,
89
+ start_date: DateLike | None = None,
90
+ end_date: DateLike | None = None,
91
+ ) -> tuple[DateLike, DateLike | None]:
92
+ if (
93
+ start is not None
94
+ and start_date is not None
95
+ and _coerce_date(start) != _coerce_date(start_date)
96
+ ):
97
+ raise ValueError("pass either start or start_date, not both")
98
+ if (
99
+ end is not None
100
+ and end_date is not None
101
+ and _coerce_date(end) != _coerce_date(end_date)
102
+ ):
103
+ raise ValueError("pass either end or end_date, not both")
104
+
105
+ resolved_start = start if start is not None else start_date
106
+ if resolved_start is None:
107
+ raise TypeError("missing required date: start")
108
+ return resolved_start, end if end is not None else end_date
109
+
110
+
111
+ class DeepTickClient:
112
+ """HTTP client for the DeepTick `/v1/*` REST API.
113
+
114
+ Args:
115
+ api_key: API key sent in the `X-API-Key` header.
116
+ base_url: API origin, with or without a trailing `/v1`.
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ api_key: str | None = None,
122
+ base_url: str = DEFAULT_BASE_URL,
123
+ *,
124
+ session: requests.Session | None = None,
125
+ timeout: int = 120,
126
+ ) -> None:
127
+ self.api_key = api_key or os.environ.get("DEEPTICK_API_KEY") or ""
128
+ self.base_url = _normalize_base_url(base_url)
129
+ self.session = session or requests.Session()
130
+ self.timeout = timeout
131
+
132
+ def catalog(self, exchange: str | None = None) -> dict[str, Any]:
133
+ """Return the downloadable dataset catalog."""
134
+ params = {"exchange": exchange} if exchange else None
135
+ return self._json("/v1/catalog", params=params)
136
+
137
+ def entitlements(self) -> dict[str, Any]:
138
+ """Return the presented API key's download entitlements."""
139
+ return self._json("/v1/entitlements")
140
+
141
+ def download(
142
+ self,
143
+ exchange: str,
144
+ data_type: str,
145
+ symbol: str,
146
+ date: DateLike,
147
+ dest: str | os.PathLike[str],
148
+ *,
149
+ columns: Iterable[str] | None = None,
150
+ format: str = "parquet",
151
+ ) -> Path:
152
+ """Download one daily partition to `dest` and return the final path."""
153
+ target = Path(dest)
154
+ target.parent.mkdir(parents=True, exist_ok=True)
155
+ tmp = target.with_name(f"{target.name}.part")
156
+
157
+ response = self._request(
158
+ "GET",
159
+ self._data_path(exchange, data_type, symbol, date),
160
+ params=self._data_params(format=format, columns=columns),
161
+ stream=True,
162
+ )
163
+ self._raise_for_status(response)
164
+ with tmp.open("wb") as fh:
165
+ for chunk in response.iter_content(chunk_size=256 * 1024):
166
+ if chunk:
167
+ fh.write(chunk)
168
+ tmp.replace(target)
169
+ return target
170
+
171
+ def load_trades(
172
+ self,
173
+ exchange: str,
174
+ symbol: str,
175
+ start: DateLike | None = None,
176
+ end: DateLike | None = None,
177
+ *,
178
+ start_date: DateLike | None = None,
179
+ end_date: DateLike | None = None,
180
+ columns: Iterable[str] | None = None,
181
+ ) -> pa.Table:
182
+ """Load trade data over an inclusive date range as a PyArrow table."""
183
+ return self.load(
184
+ "trades",
185
+ exchange,
186
+ symbol,
187
+ start,
188
+ end,
189
+ start_date=start_date,
190
+ end_date=end_date,
191
+ columns=columns,
192
+ )
193
+
194
+ def load(
195
+ self,
196
+ data_type: str,
197
+ exchange: str,
198
+ symbol: str,
199
+ start: DateLike | None = None,
200
+ end: DateLike | None = None,
201
+ *,
202
+ start_date: DateLike | None = None,
203
+ end_date: DateLike | None = None,
204
+ columns: Iterable[str] | None = None,
205
+ ) -> pa.Table:
206
+ """Load any DeepTick data type over an inclusive date range."""
207
+ start, end = _resolve_date_args(
208
+ start,
209
+ end,
210
+ start_date=start_date,
211
+ end_date=end_date,
212
+ )
213
+ tables = [
214
+ table
215
+ for day in _date_range(start, end)
216
+ if (table := self._load_day(exchange, data_type, symbol, day, columns=columns))
217
+ is not None
218
+ ]
219
+ if not tables:
220
+ return _empty_table()
221
+ return pa.concat_tables(tables, promote_options="default")
222
+
223
+ def _load_day(
224
+ self,
225
+ exchange: str,
226
+ data_type: str,
227
+ symbol: str,
228
+ day: str,
229
+ *,
230
+ columns: Iterable[str] | None = None,
231
+ ) -> pa.Table | None:
232
+ response = self._request(
233
+ "GET",
234
+ self._data_path(exchange, data_type, symbol, day),
235
+ params=self._data_params(format="parquet", columns=columns),
236
+ )
237
+ if response.status_code == 404:
238
+ return None
239
+ self._raise_for_status(response)
240
+ return pq.read_table(io.BytesIO(response.content))
241
+
242
+ def _json(self, path: str, *, params: dict[str, str] | None = None) -> dict[str, Any]:
243
+ response = self._request("GET", path, params=params)
244
+ self._raise_for_status(response)
245
+ data = response.json()
246
+ if not isinstance(data, dict):
247
+ raise DeepTickAPIError(f"expected object response from {path}")
248
+ return data
249
+
250
+ def _request(
251
+ self,
252
+ method: str,
253
+ path: str,
254
+ *,
255
+ params: dict[str, str] | None = None,
256
+ stream: bool = False,
257
+ ) -> requests.Response:
258
+ return self.session.request(
259
+ method,
260
+ f"{self.base_url}{path}",
261
+ headers=self._headers(),
262
+ params=params,
263
+ timeout=self.timeout,
264
+ stream=stream,
265
+ )
266
+
267
+ def _headers(self) -> dict[str, str]:
268
+ headers = {"User-Agent": USER_AGENT}
269
+ if self.api_key:
270
+ headers["X-API-Key"] = self.api_key
271
+ return headers
272
+
273
+ @staticmethod
274
+ def _raise_for_status(response: requests.Response) -> None:
275
+ try:
276
+ response.raise_for_status()
277
+ except requests.HTTPError as exc:
278
+ detail = response.text[:500] if response.text else response.reason
279
+ message = f"DeepTick API returned HTTP {response.status_code}: {detail}"
280
+ raise DeepTickAPIError(message) from exc
281
+
282
+ @staticmethod
283
+ def _data_path(
284
+ exchange: str,
285
+ data_type: str,
286
+ symbol: str,
287
+ day: DateLike,
288
+ ) -> str:
289
+ return (
290
+ "/v1/data/"
291
+ f"{quote(exchange, safe='')}/"
292
+ f"{quote(data_type, safe='')}/"
293
+ f"{quote(symbol, safe='')}/"
294
+ f"{quote(_coerce_date(day).isoformat(), safe='')}"
295
+ )
296
+
297
+ @staticmethod
298
+ def _data_params(
299
+ *,
300
+ format: str = "parquet",
301
+ columns: Iterable[str] | None = None,
302
+ ) -> dict[str, str]:
303
+ params = {"format": format}
304
+ if selected := _columns_param(columns):
305
+ params["columns"] = selected
306
+ return params
307
+
308
+
309
+ class CachedClient(DeepTickClient):
310
+ """DeepTick client with local daily parquet caching.
311
+
312
+ The preferred positional order matches `DeepTickClient(api_key, base_url)`.
313
+ Existing site examples that pass `base_url` first or use keywords are also
314
+ accepted.
315
+
316
+ ```python
317
+ from deeptick import CachedClient
318
+ client = CachedClient(api_key="dtk_your_key")
319
+ ```
320
+ """
321
+
322
+ def __init__(
323
+ self,
324
+ api_key: str | None = None,
325
+ base_url: str = DEFAULT_BASE_URL,
326
+ *,
327
+ cache_dir: str | os.PathLike[str] = DEFAULT_CACHE_DIR,
328
+ max_workers: int = 8,
329
+ session: requests.Session | None = None,
330
+ timeout: int = 120,
331
+ ) -> None:
332
+ api_key, base_url = _resolve_cached_client_args(api_key, base_url)
333
+ super().__init__(api_key=api_key, base_url=base_url, session=session, timeout=timeout)
334
+ self.cache_dir = Path(cache_dir)
335
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
336
+ self.max_workers = max_workers
337
+ self.cache_hits = 0
338
+ self.cache_misses = 0
339
+ self.bytes_downloaded = 0
340
+ self.bytes_from_cache = 0
341
+
342
+ def load(
343
+ self,
344
+ data_type: str,
345
+ exchange: str,
346
+ symbol: str,
347
+ start: DateLike | None = None,
348
+ end: DateLike | None = None,
349
+ *,
350
+ start_date: DateLike | None = None,
351
+ end_date: DateLike | None = None,
352
+ columns: Iterable[str] | None = None,
353
+ ) -> pa.Table:
354
+ start, end = _resolve_date_args(start, end, start_date=start_date, end_date=end_date)
355
+ days = _date_range(start, end)
356
+ if len(days) == 1:
357
+ table = self._load_day_cached(exchange, data_type, symbol, days[0], columns=columns)
358
+ return table if table is not None else _empty_table()
359
+
360
+ tables: list[tuple[str, pa.Table]] = []
361
+ with ThreadPoolExecutor(max_workers=min(self.max_workers, len(days))) as pool:
362
+ futures = {
363
+ pool.submit(
364
+ self._load_day_cached,
365
+ exchange,
366
+ data_type,
367
+ symbol,
368
+ day,
369
+ columns=columns,
370
+ ): day
371
+ for day in days
372
+ }
373
+ for future in as_completed(futures):
374
+ day = futures[future]
375
+ table = future.result()
376
+ if table is not None:
377
+ tables.append((day, table))
378
+
379
+ if not tables:
380
+ return _empty_table()
381
+ tables.sort(key=lambda item: item[0])
382
+ return pa.concat_tables([table for _, table in tables], promote_options="default")
383
+
384
+ def cache_stats(self) -> dict[str, Any]:
385
+ """Return cache counters and current disk footprint."""
386
+ files = list(self.cache_dir.rglob("*.parquet"))
387
+ total_bytes = sum(path.stat().st_size for path in files)
388
+ total_requests = self.cache_hits + self.cache_misses
389
+ total_bytes_seen = self.bytes_from_cache + self.bytes_downloaded
390
+ return {
391
+ "cache_dir": str(self.cache_dir),
392
+ "cache_hits": self.cache_hits,
393
+ "cache_misses": self.cache_misses,
394
+ "hit_rate_pct": round(self.cache_hits / max(total_requests, 1) * 100, 1),
395
+ "cached_files": len(files),
396
+ "cache_size_mb": round(total_bytes / (1024 * 1024), 2),
397
+ "bytes_downloaded": self.bytes_downloaded,
398
+ "bytes_from_cache": self.bytes_from_cache,
399
+ "bandwidth_saved_pct": round(
400
+ self.bytes_from_cache / max(total_bytes_seen, 1) * 100,
401
+ 1,
402
+ ),
403
+ }
404
+
405
+ def clear_cache(self) -> None:
406
+ """Remove all locally cached parquet files."""
407
+ shutil.rmtree(self.cache_dir, ignore_errors=True)
408
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
409
+
410
+ def _load_day_cached(
411
+ self,
412
+ exchange: str,
413
+ data_type: str,
414
+ symbol: str,
415
+ day: str,
416
+ *,
417
+ columns: Iterable[str] | None = None,
418
+ ) -> pa.Table | None:
419
+ cache_path = self._cache_path(exchange, data_type, symbol, day, columns=columns)
420
+ if cache_path.exists() and cache_path.stat().st_size > 0:
421
+ self.cache_hits += 1
422
+ self.bytes_from_cache += cache_path.stat().st_size
423
+ return pq.read_table(cache_path)
424
+
425
+ self.cache_misses += 1
426
+ table = super()._load_day(exchange, data_type, symbol, day, columns=columns)
427
+ if table is None:
428
+ return None
429
+
430
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
431
+ tmp = cache_path.with_name(f"{cache_path.name}.part")
432
+ pq.write_table(table, tmp, compression="zstd")
433
+ tmp.replace(cache_path)
434
+ self.bytes_downloaded += cache_path.stat().st_size
435
+ return table
436
+
437
+ def _cache_path(
438
+ self,
439
+ exchange: str,
440
+ data_type: str,
441
+ symbol: str,
442
+ day: str,
443
+ *,
444
+ columns: Iterable[str] | None = None,
445
+ ) -> Path:
446
+ safe_symbol = symbol.replace("/", "-").replace(":", "-")
447
+ suffix = ""
448
+ if selected := _columns_param(columns):
449
+ suffix = "_" + hashlib.md5(selected.encode("utf-8")).hexdigest()[:8]
450
+ return self.cache_dir / exchange / data_type / safe_symbol / f"{day}{suffix}.parquet"
deeptick/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.5
2
+ Name: deeptick
3
+ Version: 0.1.0
4
+ Summary: DeepTick Python SDK for authenticated market data downloads
5
+ Project-URL: Homepage, https://deeptick.lacertalabs.xyz
6
+ Project-URL: Repository, https://github.com/deeptick/deeptick
7
+ Project-URL: Documentation, https://deeptick.lacertalabs.xyz/docs
8
+ Author: DeepTick
9
+ License: MIT
10
+ Keywords: crypto,deeptick,market-data,parquet,pyarrow
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Financial and Insurance Industry
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: pyarrow>=17.0
19
+ Requires-Dist: requests>=2.31
20
+ Provides-Extra: dev
21
+ Requires-Dist: build>=1.2; extra == 'dev'
22
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # DeepTick Python SDK
26
+
27
+ ```python
28
+ from deeptick import CachedClient
29
+
30
+ client = CachedClient("dtk_your_key", "https://deeptick.dev")
31
+ trades = client.load_trades("lighter", "BTC-USD", "2026-07-08", "2026-07-08")
32
+ ```
33
+
34
+ The SDK uses the public DeepTick REST API under `/v1/*` and sends credentials
35
+ with the `X-API-Key` header.
36
+
37
+ Surface:
38
+
39
+ - `DeepTickClient(api_key, base_url="https://deeptick.dev")`
40
+ - `load_trades(exchange, symbol, start, end)` returns a `pyarrow.Table`
41
+ - `download(exchange, data_type, symbol, date, dest)` streams one partition to disk
42
+ - `entitlements()` and `catalog(exchange=None)` return API metadata
43
+ - `CachedClient` adds local Parquet caching under `~/.deeptick/cache`
@@ -0,0 +1,6 @@
1
+ deeptick/__init__.py,sha256=Js_bx76_d__OwIZWeriYLwCfMKsswI6QmZDmBjNkVIU,162
2
+ deeptick/client.py,sha256=ueqj5M7LtP8XhC-CbwUGs2TGZ6gy9low-MZK9egfx7I,14555
3
+ deeptick/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
+ deeptick-0.1.0.dist-info/METADATA,sha256=9HjpHMNBRiJU_Ph-2et_8A0lyFTNj3M8xI86ulRhqzA,1618
5
+ deeptick-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
6
+ deeptick-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any