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 +27 -0
- candlefeed/client.py +626 -0
- candlefeed/exceptions.py +66 -0
- candlefeed/py.typed +0 -0
- candlefeed-0.1.0.dist-info/METADATA +172 -0
- candlefeed-0.1.0.dist-info/RECORD +8 -0
- candlefeed-0.1.0.dist-info/WHEEL +4 -0
- candlefeed-0.1.0.dist-info/licenses/LICENSE +21 -0
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", {})
|
candlefeed/exceptions.py
ADDED
|
@@ -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,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.
|