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.
- candlefeed-0.1.0/.gitignore +8 -0
- candlefeed-0.1.0/LICENSE +21 -0
- candlefeed-0.1.0/PKG-INFO +172 -0
- candlefeed-0.1.0/README.md +134 -0
- candlefeed-0.1.0/candlefeed/__init__.py +27 -0
- candlefeed-0.1.0/candlefeed/client.py +626 -0
- candlefeed-0.1.0/candlefeed/exceptions.py +66 -0
- candlefeed-0.1.0/candlefeed/py.typed +0 -0
- candlefeed-0.1.0/examples/funding_carry_backtest.ipynb +197 -0
- candlefeed-0.1.0/pyproject.toml +76 -0
candlefeed-0.1.0/LICENSE
ADDED
|
@@ -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"]
|