candlefeed 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.
candlefeed/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """CandleFeed — the official Python client for the CandleFeed crypto market-data API.
2
+
3
+ from candlefeed import CandleFeed
4
+
5
+ cf = CandleFeed(api_key="cf_live_...")
6
+ df = cf.get_ohlcv("BTCUSDT", interval="1h", limit=5)
7
+ """
8
+ from .client import CandleFeed
9
+ from .exceptions import (
10
+ AuthenticationError,
11
+ CandleFeedError,
12
+ InvalidParameterError,
13
+ RateLimitError,
14
+ TierRestrictedError,
15
+ )
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "CandleFeed",
21
+ "CandleFeedError",
22
+ "AuthenticationError",
23
+ "TierRestrictedError",
24
+ "InvalidParameterError",
25
+ "RateLimitError",
26
+ "__version__",
27
+ ]
candlefeed/client.py ADDED
@@ -0,0 +1,626 @@
1
+ """CandleFeed API client — pandas-native access to crypto market data."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Dict, List, Optional, Union
8
+
9
+ import pandas as pd
10
+ import requests
11
+
12
+ from .exceptions import (
13
+ AuthenticationError,
14
+ CandleFeedError,
15
+ InvalidParameterError,
16
+ RateLimitError,
17
+ TierRestrictedError,
18
+ )
19
+
20
+ __all__ = ["CandleFeed"]
21
+
22
+ DEFAULT_BASE_URL = "https://candlefeed.ai/api/v1"
23
+ DEFAULT_TIMEOUT = 30.0
24
+ DEFAULT_MAX_RETRIES = 4
25
+
26
+ TimeLike = Union[str, datetime, None]
27
+
28
+ # Columns that should be coerced to float when present in a response.
29
+ _NUMERIC_COLUMNS = {
30
+ "open", "high", "low", "close", "volume", "quote_volume",
31
+ "funding_rate", "mark_price",
32
+ "weighted_funding_rate", "total_oi_usd",
33
+ "open_interest", "open_interest_value",
34
+ "quantity", "price", "usd_value",
35
+ "long_liq_usd", "short_liq_usd", "total_liq_usd",
36
+ "long_short_ratio", "long_account_ratio", "short_account_ratio",
37
+ "buy_volume", "sell_volume", "buy_sell_ratio",
38
+ "open_basis", "close_basis", "open_change", "close_change",
39
+ "strike", "mark_iv", "delta", "gamma", "vega", "theta", "rho",
40
+ "bid_price", "ask_price", "underlying_price", "index_price",
41
+ "liquidation_count", "liquidation_volume_usd",
42
+ }
43
+
44
+ # Candidate timestamp column names, in priority order. The API uses ``time`` on
45
+ # most endpoints and ``timestamp`` on the aggregated ones.
46
+ _TIME_COLUMNS = ("time", "timestamp")
47
+
48
+
49
+ def _to_iso(value: TimeLike) -> Optional[str]:
50
+ """Normalize a datetime/str to an ISO8601 string the API accepts."""
51
+ if value is None:
52
+ return None
53
+ if isinstance(value, datetime):
54
+ if value.tzinfo is None:
55
+ value = value.replace(tzinfo=timezone.utc)
56
+ return value.isoformat()
57
+ return str(value)
58
+
59
+
60
+ class CandleFeed:
61
+ """Client for the CandleFeed crypto market-data API.
62
+
63
+ Every data method returns a tidy :class:`pandas.DataFrame` indexed by the
64
+ parsed timestamp. Range requests auto-paginate (follow ``next_cursor``) and
65
+ rate limits are retried with bounded backoff.
66
+
67
+ Args:
68
+ api_key: Your CandleFeed API key. Falls back to the ``CANDLEFEED_API_KEY``
69
+ environment variable.
70
+ base_url: API base URL. Defaults to production.
71
+ timeout: Per-request timeout in seconds.
72
+ max_retries: Max retry attempts on HTTP 429 / transient network errors.
73
+ session: Optional pre-configured :class:`requests.Session`.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ api_key: Optional[str] = None,
79
+ base_url: str = DEFAULT_BASE_URL,
80
+ timeout: float = DEFAULT_TIMEOUT,
81
+ max_retries: int = DEFAULT_MAX_RETRIES,
82
+ session: Optional[requests.Session] = None,
83
+ ) -> None:
84
+ key = api_key or os.environ.get("CANDLEFEED_API_KEY")
85
+ if not key:
86
+ raise AuthenticationError(
87
+ "No API key provided. Pass api_key= or set the "
88
+ "CANDLEFEED_API_KEY environment variable. Get a key at "
89
+ "https://candlefeed.ai",
90
+ code="unauthorized",
91
+ )
92
+ self.api_key = key
93
+ self.base_url = base_url.rstrip("/")
94
+ self.timeout = timeout
95
+ self.max_retries = max_retries
96
+ self._session = session or requests.Session()
97
+ self._session.headers.update(
98
+ {
99
+ "X-API-Key": self.api_key,
100
+ "Accept": "application/json",
101
+ "User-Agent": "candlefeed-python/0.1.0",
102
+ }
103
+ )
104
+ self.last_rate_limit: Dict[str, Optional[str]] = {}
105
+
106
+ def __repr__(self) -> str:
107
+ masked = f"{self.api_key[:11]}…" if len(self.api_key) > 11 else "set"
108
+ return f"CandleFeed(base_url={self.base_url!r}, api_key={masked!r})"
109
+
110
+ def close(self) -> None:
111
+ """Close the underlying HTTP session."""
112
+ self._session.close()
113
+
114
+ def __enter__(self) -> "CandleFeed":
115
+ return self
116
+
117
+ def __exit__(self, *exc: object) -> None:
118
+ self.close()
119
+
120
+ # ------------------------------------------------------------------ #
121
+ # HTTP plumbing
122
+ # ------------------------------------------------------------------ #
123
+ def _request(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
124
+ """Issue a single GET, map errors, and retry on 429/transient failures."""
125
+ url = f"{self.base_url}/{path.lstrip('/')}"
126
+ clean = {k: v for k, v in params.items() if v is not None}
127
+
128
+ attempt = 0
129
+ while True:
130
+ try:
131
+ resp = self._session.get(url, params=clean, timeout=self.timeout)
132
+ except requests.RequestException as exc:
133
+ if attempt < self.max_retries:
134
+ time.sleep(self._backoff(attempt))
135
+ attempt += 1
136
+ continue
137
+ raise CandleFeedError(f"Request to {url} failed: {exc}") from exc
138
+
139
+ self._capture_rate_limit(resp)
140
+
141
+ if resp.status_code == 429:
142
+ retry_after = self._retry_after_seconds(resp)
143
+ if attempt < self.max_retries:
144
+ time.sleep(retry_after if retry_after is not None else self._backoff(attempt))
145
+ attempt += 1
146
+ continue
147
+ code, message = self._extract_error(resp)
148
+ raise RateLimitError(
149
+ message or "Rate limit exceeded.",
150
+ code=code or "rate_limit_exceeded",
151
+ status_code=429,
152
+ retry_after=retry_after,
153
+ )
154
+
155
+ if resp.status_code >= 400:
156
+ self._raise_for_error(resp)
157
+
158
+ try:
159
+ return resp.json()
160
+ except ValueError as exc:
161
+ raise CandleFeedError(
162
+ f"Non-JSON response from {url} (HTTP {resp.status_code})."
163
+ ) from exc
164
+
165
+ def _raise_for_error(self, resp: requests.Response) -> None:
166
+ code, message = self._extract_error(resp)
167
+ status = resp.status_code
168
+ msg = message or f"HTTP {status} error"
169
+ if status == 401:
170
+ raise AuthenticationError(msg, code=code, status_code=status)
171
+ if status == 403:
172
+ raise TierRestrictedError(msg, code=code or "tier_restricted", status_code=status)
173
+ if status in (400, 422):
174
+ raise InvalidParameterError(msg, code=code, status_code=status)
175
+ raise CandleFeedError(msg, code=code, status_code=status)
176
+
177
+ @staticmethod
178
+ def _extract_error(resp: requests.Response) -> tuple[Optional[str], Optional[str]]:
179
+ try:
180
+ body = resp.json()
181
+ except ValueError:
182
+ return None, resp.text or None
183
+ # FastAPI wraps HTTPException detail under "detail"; our handlers also
184
+ # return the envelope at the top level. Support both.
185
+ detail = body.get("detail") if isinstance(body, dict) else None
186
+ if isinstance(detail, dict):
187
+ return detail.get("code"), detail.get("message")
188
+ if isinstance(detail, str):
189
+ return None, detail
190
+ if isinstance(body, dict):
191
+ return body.get("code"), body.get("message")
192
+ return None, None
193
+
194
+ def _capture_rate_limit(self, resp: requests.Response) -> None:
195
+ for header in ("X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset", "X-Plan"):
196
+ if header in resp.headers:
197
+ self.last_rate_limit[header] = resp.headers[header]
198
+
199
+ @staticmethod
200
+ def _retry_after_seconds(resp: requests.Response) -> Optional[float]:
201
+ ra = resp.headers.get("Retry-After")
202
+ if ra is not None:
203
+ try:
204
+ return float(ra)
205
+ except ValueError:
206
+ pass
207
+ reset = resp.headers.get("X-RateLimit-Reset")
208
+ if reset:
209
+ try:
210
+ reset_dt = datetime.fromisoformat(reset.replace("Z", "+00:00"))
211
+ if reset_dt.tzinfo is None:
212
+ reset_dt = reset_dt.replace(tzinfo=timezone.utc)
213
+ delta = (reset_dt - datetime.now(timezone.utc)).total_seconds()
214
+ return max(delta, 0.0)
215
+ except ValueError:
216
+ pass
217
+ return None
218
+
219
+ @staticmethod
220
+ def _backoff(attempt: int) -> float:
221
+ # 0.5s, 1s, 2s, 4s … capped at 30s.
222
+ return min(0.5 * (2 ** attempt), 30.0)
223
+
224
+ # ------------------------------------------------------------------ #
225
+ # Pagination + DataFrame assembly
226
+ # ------------------------------------------------------------------ #
227
+ def _fetch(
228
+ self,
229
+ path: str,
230
+ params: Dict[str, Any],
231
+ paginate: bool,
232
+ max_rows: Optional[int],
233
+ ) -> List[Dict[str, Any]]:
234
+ """Fetch rows from a cursor-paginated endpoint."""
235
+ rows: List[Dict[str, Any]] = []
236
+ cursor: Optional[str] = params.get("cursor")
237
+ while True:
238
+ page_params = dict(params)
239
+ if cursor is not None:
240
+ page_params["cursor"] = cursor
241
+ body = self._request(path, page_params)
242
+ page = body.get("data") or []
243
+ rows.extend(page)
244
+
245
+ if max_rows is not None and len(rows) >= max_rows:
246
+ return rows[:max_rows]
247
+
248
+ next_cursor = body.get("next_cursor")
249
+ if not paginate or not body.get("has_more") or not next_cursor:
250
+ return rows
251
+ if next_cursor == cursor: # guard against a stuck cursor
252
+ return rows
253
+ cursor = next_cursor
254
+
255
+ @staticmethod
256
+ def _to_frame(rows: List[Dict[str, Any]], index: bool = True) -> pd.DataFrame:
257
+ """Build a tidy DataFrame: parsed datetime index, float numerics."""
258
+ df = pd.DataFrame(rows)
259
+ if df.empty:
260
+ return df
261
+
262
+ time_col = next((c for c in _TIME_COLUMNS if c in df.columns), None)
263
+ if time_col is not None:
264
+ df[time_col] = pd.to_datetime(df[time_col], utc=True, errors="coerce")
265
+
266
+ for col in df.columns:
267
+ if col in _NUMERIC_COLUMNS:
268
+ df[col] = pd.to_numeric(df[col], errors="coerce")
269
+
270
+ if index and time_col is not None:
271
+ df = df.set_index(time_col).sort_index()
272
+ return df
273
+
274
+ def _query(
275
+ self,
276
+ path: str,
277
+ params: Dict[str, Any],
278
+ paginate: bool,
279
+ max_rows: Optional[int],
280
+ ) -> pd.DataFrame:
281
+ rows = self._fetch(path, params, paginate=paginate, max_rows=max_rows)
282
+ return self._to_frame(rows)
283
+
284
+ # ------------------------------------------------------------------ #
285
+ # OHLCV / candles
286
+ # ------------------------------------------------------------------ #
287
+ def get_ohlcv(
288
+ self,
289
+ symbol: str,
290
+ exchange: str = "binance",
291
+ interval: str = "1m",
292
+ start: TimeLike = None,
293
+ end: TimeLike = None,
294
+ limit: Optional[int] = None,
295
+ paginate: bool = True,
296
+ max_rows: Optional[int] = None,
297
+ ) -> pd.DataFrame:
298
+ """Historical OHLCV candles.
299
+
300
+ Intervals: ``1m, 5m, 15m, 1h, 4h, 1d``. Returns a DataFrame indexed by
301
+ ``time`` with ``open, high, low, close, volume, quote_volume`` columns.
302
+ """
303
+ params = {
304
+ "symbol": symbol,
305
+ "exchange": exchange,
306
+ "interval": interval,
307
+ "start": _to_iso(start),
308
+ "end": _to_iso(end),
309
+ "limit": limit,
310
+ }
311
+ return self._query("candles", params, paginate, max_rows)
312
+
313
+ get_candles = get_ohlcv
314
+
315
+ # ------------------------------------------------------------------ #
316
+ # Funding rates
317
+ # ------------------------------------------------------------------ #
318
+ def get_funding_rates(
319
+ self,
320
+ symbol: str,
321
+ exchange: str = "binance",
322
+ start: TimeLike = None,
323
+ end: TimeLike = None,
324
+ limit: Optional[int] = None,
325
+ paginate: bool = True,
326
+ max_rows: Optional[int] = None,
327
+ ) -> pd.DataFrame:
328
+ """Per-exchange funding rates (``funding_rate``, ``mark_price``)."""
329
+ params = {
330
+ "symbol": symbol,
331
+ "exchange": exchange,
332
+ "start": _to_iso(start),
333
+ "end": _to_iso(end),
334
+ "limit": limit,
335
+ }
336
+ return self._query("funding-rates", params, paginate, max_rows)
337
+
338
+ def get_funding_rates_aggregated(
339
+ self,
340
+ symbol: str,
341
+ interval: str = "1h",
342
+ exchanges: Optional[Union[str, List[str]]] = None,
343
+ start: TimeLike = None,
344
+ end: TimeLike = None,
345
+ limit: Optional[int] = None,
346
+ paginate: bool = True,
347
+ max_rows: Optional[int] = None,
348
+ ) -> pd.DataFrame:
349
+ """OI-weighted funding rate across exchanges.
350
+
351
+ Intervals: ``1h, 4h, 1d``. Returns ``weighted_funding_rate``,
352
+ ``total_oi_usd``, ``exchange_count``, ``contributing_exchanges``,
353
+ indexed by ``timestamp``.
354
+ """
355
+ if isinstance(exchanges, (list, tuple)):
356
+ exchanges = ",".join(exchanges)
357
+ params = {
358
+ "symbol": symbol,
359
+ "interval": interval,
360
+ "exchanges": exchanges,
361
+ "start": _to_iso(start),
362
+ "end": _to_iso(end),
363
+ "limit": limit,
364
+ }
365
+ return self._query("funding-rates/aggregated", params, paginate, max_rows)
366
+
367
+ # ------------------------------------------------------------------ #
368
+ # Open interest
369
+ # ------------------------------------------------------------------ #
370
+ def get_open_interest(
371
+ self,
372
+ symbol: str,
373
+ exchange: str = "binance",
374
+ interval: str = "5m",
375
+ start: TimeLike = None,
376
+ end: TimeLike = None,
377
+ limit: Optional[int] = None,
378
+ paginate: bool = True,
379
+ max_rows: Optional[int] = None,
380
+ ) -> pd.DataFrame:
381
+ """Open interest (``open_interest``, ``open_interest_value``).
382
+
383
+ Intervals: ``5m, 15m, 1h, 4h, 1d``.
384
+ """
385
+ params = {
386
+ "symbol": symbol,
387
+ "exchange": exchange,
388
+ "interval": interval,
389
+ "start": _to_iso(start),
390
+ "end": _to_iso(end),
391
+ "limit": limit,
392
+ }
393
+ return self._query("open-interest", params, paginate, max_rows)
394
+
395
+ # ------------------------------------------------------------------ #
396
+ # Liquidations
397
+ # ------------------------------------------------------------------ #
398
+ def get_liquidations(
399
+ self,
400
+ symbol: str,
401
+ exchange: str = "binance",
402
+ side: Optional[str] = None,
403
+ interval: Optional[str] = None,
404
+ start: TimeLike = None,
405
+ end: TimeLike = None,
406
+ limit: Optional[int] = None,
407
+ paginate: bool = True,
408
+ max_rows: Optional[int] = None,
409
+ ) -> pd.DataFrame:
410
+ """Liquidation events.
411
+
412
+ Omit ``interval`` for tick-level rows (``side, quantity, price,
413
+ usd_value``); pass an interval (``1m, 5m, 15m, 1h, 4h, 1d``) for bucketed
414
+ ``long_liq_usd / short_liq_usd / total_liq_usd / count``. ``side`` filters
415
+ to ``long`` or ``short``.
416
+ """
417
+ params = {
418
+ "symbol": symbol,
419
+ "exchange": exchange,
420
+ "side": side,
421
+ "interval": interval,
422
+ "start": _to_iso(start),
423
+ "end": _to_iso(end),
424
+ "limit": limit,
425
+ }
426
+ return self._query("liquidations", params, paginate, max_rows)
427
+
428
+ def get_liquidations_aggregated(
429
+ self,
430
+ symbol: str,
431
+ exchange: str = "binance",
432
+ interval: str = "1d",
433
+ start: TimeLike = None,
434
+ end: TimeLike = None,
435
+ limit: Optional[int] = None,
436
+ ) -> pd.DataFrame:
437
+ """Pre-aggregated liquidation history (CoinGlass backfill).
438
+
439
+ Intervals: ``4h, 6h, 8h, 12h, 1d``. Returns ``long_liq_usd``,
440
+ ``short_liq_usd`` indexed by ``timestamp``. This endpoint is not
441
+ cursor-paginated (it returns ``meta.total``); use ``limit`` to size the
442
+ single page.
443
+ """
444
+ params = {
445
+ "symbol": symbol,
446
+ "exchange": exchange,
447
+ "interval": interval,
448
+ "start": _to_iso(start),
449
+ "end": _to_iso(end),
450
+ "limit": limit,
451
+ }
452
+ body = self._request("liquidations/aggregated", params)
453
+ return self._to_frame(body.get("data") or [])
454
+
455
+ # ------------------------------------------------------------------ #
456
+ # Long/short ratio
457
+ # ------------------------------------------------------------------ #
458
+ def get_long_short_ratio(
459
+ self,
460
+ symbol: str,
461
+ exchange: str = "binance",
462
+ interval: str = "5m",
463
+ ratio_type: Optional[str] = None,
464
+ start: TimeLike = None,
465
+ end: TimeLike = None,
466
+ limit: Optional[int] = None,
467
+ paginate: bool = True,
468
+ max_rows: Optional[int] = None,
469
+ ) -> pd.DataFrame:
470
+ """Long/short account ratio.
471
+
472
+ Intervals: ``5m, 15m, 1h, 4h, 1d``. ``ratio_type`` is one of
473
+ ``top_account`` (default), ``global_account``, or ``both``.
474
+ """
475
+ params = {
476
+ "symbol": symbol,
477
+ "exchange": exchange,
478
+ "interval": interval,
479
+ "ratio_type": ratio_type,
480
+ "start": _to_iso(start),
481
+ "end": _to_iso(end),
482
+ "limit": limit,
483
+ }
484
+ return self._query("long-short-ratio", params, paginate, max_rows)
485
+
486
+ # ------------------------------------------------------------------ #
487
+ # Taker volume
488
+ # ------------------------------------------------------------------ #
489
+ def get_taker_volume(
490
+ self,
491
+ symbol: str,
492
+ exchange: str = "binance",
493
+ start: TimeLike = None,
494
+ end: TimeLike = None,
495
+ limit: Optional[int] = None,
496
+ paginate: bool = True,
497
+ max_rows: Optional[int] = None,
498
+ ) -> pd.DataFrame:
499
+ """Taker buy/sell volume (``buy_volume, sell_volume, buy_sell_ratio``)."""
500
+ params = {
501
+ "symbol": symbol,
502
+ "exchange": exchange,
503
+ "start": _to_iso(start),
504
+ "end": _to_iso(end),
505
+ "limit": limit,
506
+ }
507
+ return self._query("taker-volume", params, paginate, max_rows)
508
+
509
+ # ------------------------------------------------------------------ #
510
+ # Basis
511
+ # ------------------------------------------------------------------ #
512
+ def get_basis(
513
+ self,
514
+ symbol: str,
515
+ exchange: str = "binance",
516
+ interval: str = "4h",
517
+ start: TimeLike = None,
518
+ end: TimeLike = None,
519
+ limit: Optional[int] = None,
520
+ paginate: bool = True,
521
+ max_rows: Optional[int] = None,
522
+ ) -> pd.DataFrame:
523
+ """Futures basis / premium (``open_basis, close_basis, ...``).
524
+
525
+ Intervals: ``1h, 4h``.
526
+ """
527
+ params = {
528
+ "symbol": symbol,
529
+ "exchange": exchange,
530
+ "interval": interval,
531
+ "start": _to_iso(start),
532
+ "end": _to_iso(end),
533
+ "limit": limit,
534
+ }
535
+ return self._query("basis", params, paginate, max_rows)
536
+
537
+ # ------------------------------------------------------------------ #
538
+ # Combined
539
+ # ------------------------------------------------------------------ #
540
+ def get_combined(
541
+ self,
542
+ symbol: str,
543
+ interval: str = "1m",
544
+ fields: Union[str, List[str]] = "ohlcv,funding_rate",
545
+ exchange: str = "binance",
546
+ start: TimeLike = None,
547
+ end: TimeLike = None,
548
+ limit: Optional[int] = None,
549
+ paginate: bool = True,
550
+ max_rows: Optional[int] = None,
551
+ ) -> pd.DataFrame:
552
+ """Time-aligned multi-dataset frame on an OHLCV base timeline.
553
+
554
+ ``fields`` is a comma-separated string or list drawn from ``ohlcv,
555
+ open_interest, funding_rate, long_short, taker_volume, liquidations``;
556
+ supplementary datasets are forward-filled onto each candle.
557
+ """
558
+ if isinstance(fields, (list, tuple)):
559
+ fields = ",".join(fields)
560
+ params = {
561
+ "symbol": symbol,
562
+ "interval": interval,
563
+ "fields": fields,
564
+ "exchange": exchange,
565
+ "start": _to_iso(start),
566
+ "end": _to_iso(end),
567
+ "limit": limit,
568
+ }
569
+ return self._query("combined", params, paginate, max_rows)
570
+
571
+ # ------------------------------------------------------------------ #
572
+ # Options
573
+ # ------------------------------------------------------------------ #
574
+ def get_options(
575
+ self,
576
+ currency: str = "BTC",
577
+ instrument_name: Optional[str] = None,
578
+ option_type: Optional[str] = None,
579
+ expiry: Optional[str] = None,
580
+ start: TimeLike = None,
581
+ end: TimeLike = None,
582
+ limit: Optional[int] = None,
583
+ paginate: bool = True,
584
+ max_rows: Optional[int] = None,
585
+ ) -> pd.DataFrame:
586
+ """Deribit options chain snapshots with greeks and IV (Pro+).
587
+
588
+ ``currency`` is ``BTC`` or ``ETH``. Optional filters: ``instrument_name``,
589
+ ``option_type`` (``C``/``P``), ``expiry`` (``YYYY-MM-DD``).
590
+ """
591
+ params = {
592
+ "currency": currency,
593
+ "instrument_name": instrument_name,
594
+ "option_type": option_type,
595
+ "expiry": expiry,
596
+ "start": _to_iso(start),
597
+ "end": _to_iso(end),
598
+ "limit": limit,
599
+ }
600
+ return self._query("options", params, paginate, max_rows)
601
+
602
+ # ------------------------------------------------------------------ #
603
+ # Metadata
604
+ # ------------------------------------------------------------------ #
605
+ def symbols(self) -> pd.DataFrame:
606
+ """Available symbols with per-exchange date coverage."""
607
+ body = self._request("symbols", {})
608
+ df = pd.DataFrame(body.get("symbols") or [])
609
+ for col in ("available_from", "available_to"):
610
+ if col in df.columns:
611
+ df[col] = pd.to_datetime(df[col], utc=True, errors="coerce")
612
+ return df
613
+
614
+ def exchanges(self) -> pd.DataFrame:
615
+ """Supported exchanges with their datasets and symbol counts."""
616
+ body = self._request("exchanges", {})
617
+ return pd.DataFrame(body.get("exchanges") or [])
618
+
619
+ def datasets(self) -> pd.DataFrame:
620
+ """Available datasets and descriptions."""
621
+ body = self._request("datasets", {})
622
+ return pd.DataFrame(body.get("datasets") or [])
623
+
624
+ def status(self) -> Dict[str, Any]:
625
+ """API health and per-dataset data freshness (raw dict)."""
626
+ return self._request("status", {})
@@ -0,0 +1,66 @@
1
+ """Exception hierarchy for the CandleFeed client."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+
7
+ class CandleFeedError(Exception):
8
+ """Base exception for all CandleFeed client errors.
9
+
10
+ Carries the API error ``code`` and ``message`` from the response envelope
11
+ (``{"status": "error", "code": ..., "message": ...}``) when available.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ code: Optional[str] = None,
18
+ status_code: Optional[int] = None,
19
+ ) -> None:
20
+ self.code = code
21
+ self.status_code = status_code
22
+ self.message = message
23
+ super().__init__(message)
24
+
25
+ def __str__(self) -> str:
26
+ parts = []
27
+ if self.status_code is not None:
28
+ parts.append(f"HTTP {self.status_code}")
29
+ if self.code:
30
+ parts.append(self.code)
31
+ prefix = f"[{' '.join(parts)}] " if parts else ""
32
+ return f"{prefix}{self.message}"
33
+
34
+
35
+ class AuthenticationError(CandleFeedError):
36
+ """Raised on HTTP 401 — missing, invalid, or revoked API key."""
37
+
38
+
39
+ class TierRestrictedError(CandleFeedError):
40
+ """Raised on HTTP 403 ``tier_restricted`` — the resource needs an upgrade.
41
+
42
+ The ``message`` echoes the API's upgrade nudge (which symbol/dataset/exchange
43
+ or history window is gated, and the plan required).
44
+ """
45
+
46
+
47
+ class InvalidParameterError(CandleFeedError):
48
+ """Raised on HTTP 400/422 — a query parameter was rejected by the API."""
49
+
50
+
51
+ class RateLimitError(CandleFeedError):
52
+ """Raised on HTTP 429 after the client's retries are exhausted.
53
+
54
+ ``retry_after`` is the server-advertised seconds until the limit resets
55
+ (from the ``Retry-After`` header), when present.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ message: str,
61
+ code: Optional[str] = None,
62
+ status_code: Optional[int] = None,
63
+ retry_after: Optional[float] = None,
64
+ ) -> None:
65
+ self.retry_after = retry_after
66
+ super().__init__(message, code=code, status_code=status_code)
candlefeed/py.typed ADDED
File without changes
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: candlefeed
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the CandleFeed crypto market-data API — OHLCV, funding, open interest, liquidations, basis, options. Returns pandas DataFrames.
5
+ Project-URL: Homepage, https://candlefeed.ai
6
+ Project-URL: Documentation, https://candlefeed.ai/docs
7
+ Project-URL: Pricing, https://candlefeed.ai/#pricing
8
+ Project-URL: Repository, https://github.com/candlefeed-ai/candlefeed-python
9
+ Author-email: CandleFeed <support@candlefeed.ai>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: algotrading,backtesting,crypto,crypto-derivatives,funding-rates,liquidations,market-data,open-interest,pandas
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Financial and Insurance Industry
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Office/Business :: Financial :: Investment
25
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.9
28
+ Requires-Dist: pandas>=1.3
29
+ Requires-Dist: requests>=2.25
30
+ Provides-Extra: dev
31
+ Requires-Dist: build>=1.0; extra == 'dev'
32
+ Requires-Dist: pytest>=7.0; extra == 'dev'
33
+ Requires-Dist: responses>=0.23; extra == 'dev'
34
+ Requires-Dist: ruff>=0.4; extra == 'dev'
35
+ Provides-Extra: polars
36
+ Requires-Dist: polars>=0.20; extra == 'polars'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # candlefeed
40
+
41
+ **The official Python client for [CandleFeed](https://candlefeed.ai) — crypto market data that lands straight in a pandas DataFrame.**
42
+
43
+ OHLCV, funding rates, open interest, liquidations, long/short ratio, taker volume, basis, and Deribit options — across Binance, Bybit, OKX, and more. Auth, cursor pagination, and rate limits are handled for you. One call, one DataFrame.
44
+
45
+ ```bash
46
+ pip install candlefeed
47
+ ```
48
+
49
+ ## Time to first DataFrame
50
+
51
+ ```python
52
+ from candlefeed import CandleFeed
53
+
54
+ cf = CandleFeed(api_key="cf_live_...") # or set CANDLEFEED_API_KEY
55
+ df = cf.get_ohlcv("BTCUSDT", interval="1h", limit=5)
56
+ print(df)
57
+ ```
58
+
59
+ ```
60
+ open high low close volume quote_volume
61
+ time
62
+ 2026-06-06 18:00:00+00:00 69841.0 70120.5 69770.0 70011.2 1183.42 8.27e+07
63
+ 2026-06-06 19:00:00+00:00 70011.2 70250.0 69905.1 70180.9 964.18 6.77e+07
64
+ ...
65
+ ```
66
+
67
+ The frame is indexed by a tz-aware `DatetimeIndex` and every numeric column is a float — ready for `.resample()`, `.rolling()`, or a backtest loop.
68
+
69
+ ## Authentication
70
+
71
+ Pass your key directly or via the environment:
72
+
73
+ ```python
74
+ cf = CandleFeed(api_key="cf_live_...")
75
+ # or
76
+ export CANDLEFEED_API_KEY=cf_live_...
77
+ cf = CandleFeed()
78
+ ```
79
+
80
+ Get a key at **[candlefeed.ai](https://candlefeed.ai)**. The free tier covers Binance majors and the last 30 days; Builder/Pro unlock full history, every exchange, and the derived datasets.
81
+
82
+ ## Endpoints
83
+
84
+ | Method | Data | Key params |
85
+ | --- | --- | --- |
86
+ | `get_ohlcv` / `get_candles` | OHLCV candles | `symbol, exchange, interval, start, end, limit` |
87
+ | `get_funding_rates` | Per-exchange funding | `symbol, exchange, ...` |
88
+ | `get_funding_rates_aggregated` | OI-weighted funding across venues | `symbol, interval, exchanges` |
89
+ | `get_open_interest` | Open interest | `symbol, exchange, interval` |
90
+ | `get_liquidations` | Liquidation events (tick or bucketed) | `symbol, exchange, side, interval` |
91
+ | `get_liquidations_aggregated` | Pre-aggregated liquidation history | `symbol, exchange, interval` |
92
+ | `get_long_short_ratio` | Long/short account ratio | `symbol, exchange, interval, ratio_type` |
93
+ | `get_taker_volume` | Taker buy/sell volume | `symbol, exchange, ...` |
94
+ | `get_basis` | Futures basis / premium | `symbol, exchange, interval` |
95
+ | `get_combined` | Time-aligned multi-dataset frame | `symbol, interval, fields` |
96
+ | `get_options` | Deribit options chain + greeks | `currency, instrument_name, expiry` |
97
+ | `symbols` / `exchanges` / `datasets` / `status` | Metadata | — |
98
+
99
+ **Intervals** — OHLCV: `1m 5m 15m 1h 4h 1d` · aggregated funding: `1h 4h 1d` · open interest: `5m 15m 1h 4h 1d` · aggregated liquidations: `4h 6h 8h 12h 1d` · basis: `1h 4h`.
100
+
101
+ ## Date ranges auto-paginate
102
+
103
+ Ask for a window and the client transparently follows the API's `next_cursor` until the range is complete, concatenating into one DataFrame:
104
+
105
+ ```python
106
+ df = cf.get_funding_rates(
107
+ "BTCUSDT", exchange="binance",
108
+ start="2026-01-01", end="2026-03-01",
109
+ ) # many pages → a single tidy frame
110
+ ```
111
+
112
+ Cap the result with `max_rows=...`, control page size with `limit=...`, or disable
113
+ auto-paging entirely with `paginate=False` to fetch a single page and inspect the cursor yourself.
114
+
115
+ ## Multi-exchange, time-aligned
116
+
117
+ ```python
118
+ agg = cf.get_funding_rates_aggregated(
119
+ "BTCUSDT", interval="1h",
120
+ exchanges=["binance", "bybit", "okx"],
121
+ )
122
+ agg[["weighted_funding_rate", "total_oi_usd", "exchange_count"]].tail()
123
+ ```
124
+
125
+ ```python
126
+ panel = cf.get_combined(
127
+ "BTCUSDT", interval="1h",
128
+ fields=["ohlcv", "funding_rate", "open_interest"],
129
+ ) # OHLCV base timeline with funding + OI forward-filled onto each candle
130
+ ```
131
+
132
+ ## Error handling
133
+
134
+ Every API error maps to a typed exception carrying the API `code` and `message`:
135
+
136
+ ```python
137
+ from candlefeed import (
138
+ CandleFeed, AuthenticationError, TierRestrictedError,
139
+ InvalidParameterError, RateLimitError,
140
+ )
141
+
142
+ cf = CandleFeed(api_key="cf_live_...")
143
+ try:
144
+ df = cf.get_options(currency="BTC")
145
+ except TierRestrictedError as e:
146
+ print(e.message) # "...requires Pro tier or higher... Upgrade at https://candlefeed.ai/pricing"
147
+ except RateLimitError as e:
148
+ print("retry after", e.retry_after, "seconds")
149
+ except (AuthenticationError, InvalidParameterError) as e:
150
+ print(e.code, e.message)
151
+ ```
152
+
153
+ | Exception | HTTP | When |
154
+ | --- | --- | --- |
155
+ | `AuthenticationError` | 401 | missing / invalid / revoked key |
156
+ | `TierRestrictedError` | 403 | symbol, dataset, exchange, or history window above your plan |
157
+ | `InvalidParameterError` | 400 / 422 | bad symbol, interval, or timestamp |
158
+ | `RateLimitError` | 429 | raised only after the client's bounded backoff retries are exhausted |
159
+ | `CandleFeedError` | — | base class; network / unexpected errors |
160
+
161
+ On HTTP 429 the client honors `Retry-After` / `X-RateLimit-Reset` and retries with exponential backoff before giving up. Remaining-quota headers are exposed on `cf.last_rate_limit`.
162
+
163
+ ## Requirements
164
+
165
+ Python ≥ 3.9, `requests`, and `pandas`. That's the whole dependency surface.
166
+
167
+ ## Links
168
+
169
+ - Site & API keys — **https://candlefeed.ai**
170
+ - Pricing — **https://candlefeed.ai/#pricing**
171
+
172
+ MIT licensed.
@@ -0,0 +1,8 @@
1
+ candlefeed/__init__.py,sha256=ZhbD3rVNZ-DyfsRH2NWxUuXLkQrGvhREJnEMcViqWT4,612
2
+ candlefeed/client.py,sha256=C6p7O5GAwYy2zM119QWY64dAB5lmqC7hWLR4op3-ceE,22613
3
+ candlefeed/exceptions.py,sha256=Gyn5Wk8Pp8MvqTOZ90IZc9M7vhW26IKAuwtu56iHHuc,2019
4
+ candlefeed/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ candlefeed-0.1.0.dist-info/METADATA,sha256=3QaeO5RSTBtnhicW_l-lxPOBs7clUGpaCQx72ujj3jc,6931
6
+ candlefeed-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
+ candlefeed-0.1.0.dist-info/licenses/LICENSE,sha256=Zr-qEO5jtbsQMwhQqpQRpWBi43byLeeGPwC1rOaffn4,1067
8
+ candlefeed-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CandleFeed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.