candlefeed 0.1.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.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ *.pyc
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -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.
@@ -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,134 @@
1
+ # candlefeed
2
+
3
+ **The official Python client for [CandleFeed](https://candlefeed.ai) — crypto market data that lands straight in a pandas DataFrame.**
4
+
5
+ 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.
6
+
7
+ ```bash
8
+ pip install candlefeed
9
+ ```
10
+
11
+ ## Time to first DataFrame
12
+
13
+ ```python
14
+ from candlefeed import CandleFeed
15
+
16
+ cf = CandleFeed(api_key="cf_live_...") # or set CANDLEFEED_API_KEY
17
+ df = cf.get_ohlcv("BTCUSDT", interval="1h", limit=5)
18
+ print(df)
19
+ ```
20
+
21
+ ```
22
+ open high low close volume quote_volume
23
+ time
24
+ 2026-06-06 18:00:00+00:00 69841.0 70120.5 69770.0 70011.2 1183.42 8.27e+07
25
+ 2026-06-06 19:00:00+00:00 70011.2 70250.0 69905.1 70180.9 964.18 6.77e+07
26
+ ...
27
+ ```
28
+
29
+ The frame is indexed by a tz-aware `DatetimeIndex` and every numeric column is a float — ready for `.resample()`, `.rolling()`, or a backtest loop.
30
+
31
+ ## Authentication
32
+
33
+ Pass your key directly or via the environment:
34
+
35
+ ```python
36
+ cf = CandleFeed(api_key="cf_live_...")
37
+ # or
38
+ export CANDLEFEED_API_KEY=cf_live_...
39
+ cf = CandleFeed()
40
+ ```
41
+
42
+ 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.
43
+
44
+ ## Endpoints
45
+
46
+ | Method | Data | Key params |
47
+ | --- | --- | --- |
48
+ | `get_ohlcv` / `get_candles` | OHLCV candles | `symbol, exchange, interval, start, end, limit` |
49
+ | `get_funding_rates` | Per-exchange funding | `symbol, exchange, ...` |
50
+ | `get_funding_rates_aggregated` | OI-weighted funding across venues | `symbol, interval, exchanges` |
51
+ | `get_open_interest` | Open interest | `symbol, exchange, interval` |
52
+ | `get_liquidations` | Liquidation events (tick or bucketed) | `symbol, exchange, side, interval` |
53
+ | `get_liquidations_aggregated` | Pre-aggregated liquidation history | `symbol, exchange, interval` |
54
+ | `get_long_short_ratio` | Long/short account ratio | `symbol, exchange, interval, ratio_type` |
55
+ | `get_taker_volume` | Taker buy/sell volume | `symbol, exchange, ...` |
56
+ | `get_basis` | Futures basis / premium | `symbol, exchange, interval` |
57
+ | `get_combined` | Time-aligned multi-dataset frame | `symbol, interval, fields` |
58
+ | `get_options` | Deribit options chain + greeks | `currency, instrument_name, expiry` |
59
+ | `symbols` / `exchanges` / `datasets` / `status` | Metadata | — |
60
+
61
+ **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`.
62
+
63
+ ## Date ranges auto-paginate
64
+
65
+ Ask for a window and the client transparently follows the API's `next_cursor` until the range is complete, concatenating into one DataFrame:
66
+
67
+ ```python
68
+ df = cf.get_funding_rates(
69
+ "BTCUSDT", exchange="binance",
70
+ start="2026-01-01", end="2026-03-01",
71
+ ) # many pages → a single tidy frame
72
+ ```
73
+
74
+ Cap the result with `max_rows=...`, control page size with `limit=...`, or disable
75
+ auto-paging entirely with `paginate=False` to fetch a single page and inspect the cursor yourself.
76
+
77
+ ## Multi-exchange, time-aligned
78
+
79
+ ```python
80
+ agg = cf.get_funding_rates_aggregated(
81
+ "BTCUSDT", interval="1h",
82
+ exchanges=["binance", "bybit", "okx"],
83
+ )
84
+ agg[["weighted_funding_rate", "total_oi_usd", "exchange_count"]].tail()
85
+ ```
86
+
87
+ ```python
88
+ panel = cf.get_combined(
89
+ "BTCUSDT", interval="1h",
90
+ fields=["ohlcv", "funding_rate", "open_interest"],
91
+ ) # OHLCV base timeline with funding + OI forward-filled onto each candle
92
+ ```
93
+
94
+ ## Error handling
95
+
96
+ Every API error maps to a typed exception carrying the API `code` and `message`:
97
+
98
+ ```python
99
+ from candlefeed import (
100
+ CandleFeed, AuthenticationError, TierRestrictedError,
101
+ InvalidParameterError, RateLimitError,
102
+ )
103
+
104
+ cf = CandleFeed(api_key="cf_live_...")
105
+ try:
106
+ df = cf.get_options(currency="BTC")
107
+ except TierRestrictedError as e:
108
+ print(e.message) # "...requires Pro tier or higher... Upgrade at https://candlefeed.ai/pricing"
109
+ except RateLimitError as e:
110
+ print("retry after", e.retry_after, "seconds")
111
+ except (AuthenticationError, InvalidParameterError) as e:
112
+ print(e.code, e.message)
113
+ ```
114
+
115
+ | Exception | HTTP | When |
116
+ | --- | --- | --- |
117
+ | `AuthenticationError` | 401 | missing / invalid / revoked key |
118
+ | `TierRestrictedError` | 403 | symbol, dataset, exchange, or history window above your plan |
119
+ | `InvalidParameterError` | 400 / 422 | bad symbol, interval, or timestamp |
120
+ | `RateLimitError` | 429 | raised only after the client's bounded backoff retries are exhausted |
121
+ | `CandleFeedError` | — | base class; network / unexpected errors |
122
+
123
+ 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`.
124
+
125
+ ## Requirements
126
+
127
+ Python ≥ 3.9, `requests`, and `pandas`. That's the whole dependency surface.
128
+
129
+ ## Links
130
+
131
+ - Site & API keys — **https://candlefeed.ai**
132
+ - Pricing — **https://candlefeed.ai/#pricing**
133
+
134
+ MIT licensed.
@@ -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
+ ]
@@ -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)
File without changes
@@ -0,0 +1,197 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Funding-Rate Carry Backtest with `candlefeed`\n",
8
+ "\n",
9
+ "Perpetual funding is a recurring cash flow: when funding is **positive**, shorts get paid by longs. A classic market-neutral carry trade is to **short the perp and hold spot** (or hedge on another venue), collecting funding while staying delta-flat.\n",
10
+ "\n",
11
+ "This notebook uses the official [CandleFeed](https://candlefeed.ai) Python client to:\n",
12
+ "\n",
13
+ "1. Pull **OI-weighted aggregated funding** across Binance / Bybit / OKX (free-tier-friendly).\n",
14
+ "2. Visualise the carry and its cumulative payoff.\n",
15
+ "3. Run a simple **short-perp carry backtest** on the full-history per-exchange funding series (a paid-tier range) and compute annualised yield.\n",
16
+ "\n",
17
+ "> Every call returns a tidy pandas DataFrame indexed by time — so the whole thing is `.cumsum()` and `.plot()` from here."
18
+ ]
19
+ },
20
+ {
21
+ "cell_type": "code",
22
+ "execution_count": null,
23
+ "metadata": {},
24
+ "outputs": [],
25
+ "source": [
26
+ "# pip install candlefeed matplotlib\n",
27
+ "import os\n",
28
+ "import pandas as pd\n",
29
+ "import matplotlib.pyplot as plt\n",
30
+ "from candlefeed import CandleFeed\n",
31
+ "\n",
32
+ "# Set CANDLEFEED_API_KEY in your environment, or pass api_key=\"cf_live_...\"\n",
33
+ "cf = CandleFeed(api_key=os.environ.get(\"CANDLEFEED_API_KEY\"))\n",
34
+ "cf"
35
+ ]
36
+ },
37
+ {
38
+ "cell_type": "markdown",
39
+ "metadata": {},
40
+ "source": [
41
+ "## 1. Aggregated funding across venues (free tier)\n",
42
+ "\n",
43
+ "`get_funding_rates_aggregated` returns the open-interest-weighted funding rate across the exchanges where CandleFeed has both funding and OI — the single number that best represents \"the market's\" funding for a symbol."
44
+ ]
45
+ },
46
+ {
47
+ "cell_type": "code",
48
+ "execution_count": null,
49
+ "metadata": {},
50
+ "outputs": [],
51
+ "source": [
52
+ "agg = cf.get_funding_rates_aggregated(\n",
53
+ " \"BTCUSDT\",\n",
54
+ " interval=\"1h\",\n",
55
+ " exchanges=[\"binance\", \"bybit\", \"okx\"],\n",
56
+ " start=\"2024-01-01\",\n",
57
+ " end=\"2024-04-01\",\n",
58
+ ")\n",
59
+ "agg[[\"weighted_funding_rate\", \"total_oi_usd\", \"exchange_count\"]].head()"
60
+ ]
61
+ },
62
+ {
63
+ "cell_type": "code",
64
+ "execution_count": null,
65
+ "metadata": {},
66
+ "outputs": [],
67
+ "source": [
68
+ "# A short-perp carry earns the funding rate each period when funding > 0.\n",
69
+ "# Cumulative carry = running sum of the weighted funding rate.\n",
70
+ "carry = agg[\"weighted_funding_rate\"].astype(float)\n",
71
+ "\n",
72
+ "fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 7), sharex=True)\n",
73
+ "carry.plot(ax=ax1, lw=0.8)\n",
74
+ "ax1.axhline(0, color=\"k\", lw=0.6)\n",
75
+ "ax1.set_title(\"BTCUSDT OI-weighted funding rate (per interval)\")\n",
76
+ "ax1.set_ylabel(\"funding rate\")\n",
77
+ "\n",
78
+ "carry.cumsum().plot(ax=ax2, color=\"C2\")\n",
79
+ "ax2.set_title(\"Cumulative short-perp carry (sum of funding collected)\")\n",
80
+ "ax2.set_ylabel(\"cumulative funding\")\n",
81
+ "plt.tight_layout()"
82
+ ]
83
+ },
84
+ {
85
+ "cell_type": "markdown",
86
+ "metadata": {},
87
+ "source": [
88
+ "## 2. Per-exchange funding — which venue pays best?\n",
89
+ "\n",
90
+ "Funding isn't uniform across exchanges. Pulling each venue's raw funding series lets you pick the richest one to short. `get_funding_rates` auto-paginates the full requested range into one frame."
91
+ ]
92
+ },
93
+ {
94
+ "cell_type": "code",
95
+ "execution_count": null,
96
+ "metadata": {},
97
+ "outputs": [],
98
+ "source": [
99
+ "venues = [\"binance\", \"bybit\", \"okx\"]\n",
100
+ "series = {}\n",
101
+ "for ex in venues:\n",
102
+ " fr = cf.get_funding_rates(\"BTCUSDT\", exchange=ex, start=\"2024-01-01\", end=\"2024-04-01\")\n",
103
+ " series[ex] = fr[\"funding_rate\"].astype(float)\n",
104
+ "\n",
105
+ "funding = pd.DataFrame(series).sort_index()\n",
106
+ "funding.describe()"
107
+ ]
108
+ },
109
+ {
110
+ "cell_type": "code",
111
+ "execution_count": null,
112
+ "metadata": {},
113
+ "outputs": [],
114
+ "source": [
115
+ "# Resample to daily realised funding per venue and compare cumulative payoff.\n",
116
+ "daily = funding.resample(\"1D\").sum()\n",
117
+ "daily.cumsum().plot(figsize=(11, 5), title=\"Cumulative funding collected by shorting the perp, by venue\")\n",
118
+ "plt.ylabel(\"cumulative funding\")\n",
119
+ "plt.axhline(0, color=\"k\", lw=0.6);"
120
+ ]
121
+ },
122
+ {
123
+ "cell_type": "markdown",
124
+ "metadata": {},
125
+ "source": [
126
+ "## 3. Carry backtest + annualised yield (paid-tier full history)\n",
127
+ "\n",
128
+ "Below we extend the window to **full history** on the richest venue. Free tier is limited to the last 30 days — the cell below needs **Builder or above** (it will raise `TierRestrictedError` on free, which we catch and explain).\n",
129
+ "\n",
130
+ "The strategy: short 1 unit of perp, hold the hedge, collect funding every interval. We approximate the gross carry PnL as the cumulative funding and annualise it."
131
+ ]
132
+ },
133
+ {
134
+ "cell_type": "code",
135
+ "execution_count": null,
136
+ "metadata": {},
137
+ "outputs": [],
138
+ "source": [
139
+ "from candlefeed import TierRestrictedError\n",
140
+ "\n",
141
+ "BEST_VENUE = \"binance\" # swap for whichever venue paid best above\n",
142
+ "\n",
143
+ "try:\n",
144
+ " fr = cf.get_funding_rates(\"BTCUSDT\", exchange=BEST_VENUE, start=\"2021-01-01\", end=\"2024-12-31\")\n",
145
+ " rate = fr[\"funding_rate\"].astype(float)\n",
146
+ "\n",
147
+ " # Funding posts every 8h on Binance => 3 intervals/day, ~1095/yr.\n",
148
+ " periods_per_year = 365 * 3\n",
149
+ " mean_per_period = rate.mean()\n",
150
+ " annualised = mean_per_period * periods_per_year\n",
151
+ "\n",
152
+ " print(f\"Periods: {len(rate):,}\")\n",
153
+ " print(f\"Mean funding / period: {mean_per_period:.6%}\")\n",
154
+ " print(f\"Total funding collected: {rate.sum():.4%}\")\n",
155
+ " print(f\"Annualised carry yield: {annualised:.2%}\")\n",
156
+ "\n",
157
+ " rate.cumsum().plot(figsize=(11, 5), color=\"C3\",\n",
158
+ " title=f\"BTCUSDT short-perp cumulative carry — {BEST_VENUE}, full history\")\n",
159
+ " plt.ylabel(\"cumulative funding\")\n",
160
+ " plt.axhline(0, color=\"k\", lw=0.6)\n",
161
+ "except TierRestrictedError as e:\n",
162
+ " print(\"This deep-history backtest needs a paid plan:\")\n",
163
+ " print(\" \", e.message)"
164
+ ]
165
+ },
166
+ {
167
+ "cell_type": "markdown",
168
+ "metadata": {},
169
+ "source": [
170
+ "## Caveats\n",
171
+ "\n",
172
+ "This is a teaching example, not a production strategy. A real carry book must account for:\n",
173
+ "\n",
174
+ "- **Hedge cost & basis drift** — shorting the perp leaves you long spot (or long another venue); financing and basis moves both bite.\n",
175
+ "- **Funding sign flips** — funding goes negative in bearish regimes, inverting the trade.\n",
176
+ "- **Fees, slippage, and liquidation risk** on the short leg.\n",
177
+ "\n",
178
+ "Layer in OHLCV (`cf.get_ohlcv`), open interest (`cf.get_open_interest`), and basis (`cf.get_basis`) — or pull them time-aligned in one call with `cf.get_combined(..., fields=[\"ohlcv\", \"funding_rate\", \"open_interest\"])` — to build the full picture.\n",
179
+ "\n",
180
+ "Docs and API keys: **[candlefeed.ai](https://candlefeed.ai)**."
181
+ ]
182
+ }
183
+ ],
184
+ "metadata": {
185
+ "kernelspec": {
186
+ "display_name": "Python 3",
187
+ "language": "python",
188
+ "name": "python3"
189
+ },
190
+ "language_info": {
191
+ "name": "python",
192
+ "version": "3.9"
193
+ }
194
+ },
195
+ "nbformat": 4,
196
+ "nbformat_minor": 5
197
+ }
@@ -0,0 +1,76 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "candlefeed"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the CandleFeed crypto market-data API — OHLCV, funding, open interest, liquidations, basis, options. Returns pandas DataFrames."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "CandleFeed", email = "support@candlefeed.ai" }]
13
+ keywords = [
14
+ "crypto",
15
+ "algotrading",
16
+ "backtesting",
17
+ "market-data",
18
+ "crypto-derivatives",
19
+ "funding-rates",
20
+ "open-interest",
21
+ "liquidations",
22
+ "pandas",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Intended Audience :: Financial and Insurance Industry",
27
+ "Intended Audience :: Developers",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Operating System :: OS Independent",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.9",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
36
+ "Topic :: Office/Business :: Financial :: Investment",
37
+ "Topic :: Scientific/Engineering :: Information Analysis",
38
+ "Typing :: Typed",
39
+ ]
40
+ dependencies = [
41
+ "requests>=2.25",
42
+ "pandas>=1.3",
43
+ ]
44
+
45
+ [project.optional-dependencies]
46
+ polars = ["polars>=0.20"]
47
+ dev = [
48
+ "pytest>=7.0",
49
+ "responses>=0.23",
50
+ "ruff>=0.4",
51
+ "build>=1.0",
52
+ ]
53
+
54
+ [project.urls]
55
+ Homepage = "https://candlefeed.ai"
56
+ Documentation = "https://candlefeed.ai/docs"
57
+ Pricing = "https://candlefeed.ai/#pricing"
58
+ Repository = "https://github.com/candlefeed-ai/candlefeed-python"
59
+
60
+ [tool.hatch.build.targets.wheel]
61
+ packages = ["candlefeed"]
62
+
63
+ [tool.hatch.build.targets.sdist]
64
+ include = ["candlefeed", "README.md", "LICENSE", "examples"]
65
+
66
+ [tool.pytest.ini_options]
67
+ testpaths = ["tests"]
68
+ addopts = "-q"
69
+
70
+ [tool.ruff]
71
+ line-length = 100
72
+ target-version = "py39"
73
+
74
+ [tool.ruff.lint]
75
+ select = ["E", "F", "I", "W", "B"]
76
+ ignore = ["E501"]