cryptoetf 1.0.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.
- cryptoetf-1.0.0/.gitignore +4 -0
- cryptoetf-1.0.0/LICENSE +21 -0
- cryptoetf-1.0.0/PKG-INFO +129 -0
- cryptoetf-1.0.0/README.md +107 -0
- cryptoetf-1.0.0/pyproject.toml +35 -0
- cryptoetf-1.0.0/src/cryptoetf/__init__.py +7 -0
- cryptoetf-1.0.0/src/cryptoetf/client.py +170 -0
- cryptoetf-1.0.0/src/cryptoetf/py.typed +0 -0
- cryptoetf-1.0.0/src/cryptoetf/types.py +143 -0
- cryptoetf-1.0.0/tests/test_client.py +69 -0
cryptoetf-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cryptoetf.today
|
|
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.
|
cryptoetf-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cryptoetf
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for the CryptoETF API: daily net flows of US spot crypto ETFs for 16 assets (BTC, ETH, SOL, XRP, HYPE, DOGE, LINK, AVAX, HBAR, LTC, BNB, DOT, SUI, NEAR, TRX, ZEC), fund by fund, plus fund AUM, the CEFI sentiment index and coin prices. Zero dependencies.
|
|
5
|
+
Project-URL: Homepage, https://cryptoetf.today/en/api
|
|
6
|
+
Project-URL: Documentation, https://docs.cryptoetf.today
|
|
7
|
+
Project-URL: Source, https://github.com/zerosix-studio/cryptoetf-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/zerosix-studio/cryptoetf-sdk/issues
|
|
9
|
+
Author: cryptoetf.today
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,bitcoin-etf,crypto,etf,etf-flows,ethereum-etf,ibit,sdk,spot-etf
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# CryptoETF — Python SDK for spot crypto ETF flows
|
|
24
|
+
|
|
25
|
+
[](https://pypi.org/project/cryptoetf/)
|
|
26
|
+
|
|
27
|
+
The official client for the [CryptoETF API](https://docs.cryptoetf.today): daily net
|
|
28
|
+
flows of every US-listed spot crypto ETF, fund by fund, plus fund AUM, the CEFI
|
|
29
|
+
sentiment index and coin prices. Data by [cryptoetf.today](https://cryptoetf.today).
|
|
30
|
+
|
|
31
|
+
Sixteen assets: **BTC, ETH, SOL, XRP, HYPE, DOGE, LINK, AVAX, HBAR, LTC, BNB,
|
|
32
|
+
DOT, SUI, NEAR, TRX, ZEC**. Zero dependencies (standard library only), typed,
|
|
33
|
+
Python 3.9+.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install cryptoetf
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Get an API key
|
|
42
|
+
|
|
43
|
+
Sign in at [cryptoetf.today](https://cryptoetf.today/en/profile) and create a key
|
|
44
|
+
in **Profile → For developers**. It is free.
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from cryptoetf import CryptoETF
|
|
50
|
+
|
|
51
|
+
etf = CryptoETF(api_key="...") # or set CRYPTOETF_API_KEY
|
|
52
|
+
|
|
53
|
+
# Latest daily net flow for every asset, US$ millions
|
|
54
|
+
for a in etf.flows.summary()["assets"]:
|
|
55
|
+
print(a["symbol"], a["date"], a["netFlowUsdM"])
|
|
56
|
+
|
|
57
|
+
# Bitcoin ETFs fund by fund
|
|
58
|
+
btc = etf.flows.funds("btc")
|
|
59
|
+
last_day = btc["days"][-1]
|
|
60
|
+
print([(f["ticker"], f["netFlowUsdM"]) for f in last_day["funds"]])
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Responses are plain dicts with the same field names as the API, so they drop
|
|
64
|
+
straight into pandas:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import pandas as pd
|
|
68
|
+
|
|
69
|
+
df = pd.DataFrame(etf.flows.asset("eth")["days"])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Methods
|
|
73
|
+
|
|
74
|
+
| Method | Endpoint | Returns |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `flows.summary()` | `GET /v1/flows/summary` | Latest net flow per asset |
|
|
77
|
+
| `flows.asset(asset)` | `GET /v1/flows/{asset}` | Daily net flows of one asset |
|
|
78
|
+
| `flows.funds(asset)` | `GET /v1/flows/{asset}/funds` | Daily net flows of one asset, per fund |
|
|
79
|
+
| `funds(asset=None)` | `GET /v1/funds` | Fund directory with AUM, fees, coins held |
|
|
80
|
+
| `cefi()` | `GET /v1/index/cefi` | CEFI sentiment index (0–100) and its history |
|
|
81
|
+
| `tickers()` | `GET /v1/tickers` | Tracked assets |
|
|
82
|
+
| `prices()` | `GET /v1/prices` | Spot prices of the 16 coins |
|
|
83
|
+
| `weekly()` | `GET /v1/analytics/weekly` | Seven-day totals per asset |
|
|
84
|
+
|
|
85
|
+
`asset` is a code such as `btc`, `eth`, `sol`, `hype` or a full name such as
|
|
86
|
+
`bitcoin`. All values are net US$ millions: positive = inflow, negative = outflow.
|
|
87
|
+
Full field reference: [docs.cryptoetf.today](https://docs.cryptoetf.today).
|
|
88
|
+
|
|
89
|
+
## Errors and limits
|
|
90
|
+
|
|
91
|
+
Every failed call raises `CryptoETFError` with `status`, an optional `code` and,
|
|
92
|
+
on HTTP 429, `retry_after` in seconds:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from cryptoetf import CryptoETFError
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
etf.prices()
|
|
99
|
+
except CryptoETFError as e:
|
|
100
|
+
if e.status == 429:
|
|
101
|
+
print(f"rate limited, retry in {e.retry_after}s")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- **Rate limit:** 120 requests per minute per key. The latest limit headers are
|
|
105
|
+
available as `etf.rate_limit`.
|
|
106
|
+
- **History:** the free tier covers the **last 30 days**. Full history will be
|
|
107
|
+
available on a paid plan.
|
|
108
|
+
- **Freshness:** a trading day appears the next morning (UTC), before the US
|
|
109
|
+
open. The newest day can be revised once later that day. SUI funds arrive a
|
|
110
|
+
day later.
|
|
111
|
+
|
|
112
|
+
## Options
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
CryptoETF(
|
|
116
|
+
api_key="...", # default: CRYPTOETF_API_KEY
|
|
117
|
+
timeout=15.0, # seconds per request
|
|
118
|
+
base_url="https://api.cryptoetf.today/api/v1",
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Related
|
|
123
|
+
|
|
124
|
+
- **TypeScript SDK:** [@zerosix-studio/cryptoetf](https://www.npmjs.com/package/@zerosix-studio/cryptoetf)
|
|
125
|
+
- **No-code access for AI agents:** the hosted MCP server at
|
|
126
|
+
`https://mcp.cryptoetf.today/api/mcp`
|
|
127
|
+
- **Embeddable widgets:** [cryptoetf.today/en/crypto-etf-widgets](https://cryptoetf.today/en/crypto-etf-widgets)
|
|
128
|
+
|
|
129
|
+
Not investment advice. MIT licensed.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# CryptoETF — Python SDK for spot crypto ETF flows
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/cryptoetf/)
|
|
4
|
+
|
|
5
|
+
The official client for the [CryptoETF API](https://docs.cryptoetf.today): daily net
|
|
6
|
+
flows of every US-listed spot crypto ETF, fund by fund, plus fund AUM, the CEFI
|
|
7
|
+
sentiment index and coin prices. Data by [cryptoetf.today](https://cryptoetf.today).
|
|
8
|
+
|
|
9
|
+
Sixteen assets: **BTC, ETH, SOL, XRP, HYPE, DOGE, LINK, AVAX, HBAR, LTC, BNB,
|
|
10
|
+
DOT, SUI, NEAR, TRX, ZEC**. Zero dependencies (standard library only), typed,
|
|
11
|
+
Python 3.9+.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install cryptoetf
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Get an API key
|
|
20
|
+
|
|
21
|
+
Sign in at [cryptoetf.today](https://cryptoetf.today/en/profile) and create a key
|
|
22
|
+
in **Profile → For developers**. It is free.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from cryptoetf import CryptoETF
|
|
28
|
+
|
|
29
|
+
etf = CryptoETF(api_key="...") # or set CRYPTOETF_API_KEY
|
|
30
|
+
|
|
31
|
+
# Latest daily net flow for every asset, US$ millions
|
|
32
|
+
for a in etf.flows.summary()["assets"]:
|
|
33
|
+
print(a["symbol"], a["date"], a["netFlowUsdM"])
|
|
34
|
+
|
|
35
|
+
# Bitcoin ETFs fund by fund
|
|
36
|
+
btc = etf.flows.funds("btc")
|
|
37
|
+
last_day = btc["days"][-1]
|
|
38
|
+
print([(f["ticker"], f["netFlowUsdM"]) for f in last_day["funds"]])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Responses are plain dicts with the same field names as the API, so they drop
|
|
42
|
+
straight into pandas:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import pandas as pd
|
|
46
|
+
|
|
47
|
+
df = pd.DataFrame(etf.flows.asset("eth")["days"])
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Methods
|
|
51
|
+
|
|
52
|
+
| Method | Endpoint | Returns |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `flows.summary()` | `GET /v1/flows/summary` | Latest net flow per asset |
|
|
55
|
+
| `flows.asset(asset)` | `GET /v1/flows/{asset}` | Daily net flows of one asset |
|
|
56
|
+
| `flows.funds(asset)` | `GET /v1/flows/{asset}/funds` | Daily net flows of one asset, per fund |
|
|
57
|
+
| `funds(asset=None)` | `GET /v1/funds` | Fund directory with AUM, fees, coins held |
|
|
58
|
+
| `cefi()` | `GET /v1/index/cefi` | CEFI sentiment index (0–100) and its history |
|
|
59
|
+
| `tickers()` | `GET /v1/tickers` | Tracked assets |
|
|
60
|
+
| `prices()` | `GET /v1/prices` | Spot prices of the 16 coins |
|
|
61
|
+
| `weekly()` | `GET /v1/analytics/weekly` | Seven-day totals per asset |
|
|
62
|
+
|
|
63
|
+
`asset` is a code such as `btc`, `eth`, `sol`, `hype` or a full name such as
|
|
64
|
+
`bitcoin`. All values are net US$ millions: positive = inflow, negative = outflow.
|
|
65
|
+
Full field reference: [docs.cryptoetf.today](https://docs.cryptoetf.today).
|
|
66
|
+
|
|
67
|
+
## Errors and limits
|
|
68
|
+
|
|
69
|
+
Every failed call raises `CryptoETFError` with `status`, an optional `code` and,
|
|
70
|
+
on HTTP 429, `retry_after` in seconds:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from cryptoetf import CryptoETFError
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
etf.prices()
|
|
77
|
+
except CryptoETFError as e:
|
|
78
|
+
if e.status == 429:
|
|
79
|
+
print(f"rate limited, retry in {e.retry_after}s")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- **Rate limit:** 120 requests per minute per key. The latest limit headers are
|
|
83
|
+
available as `etf.rate_limit`.
|
|
84
|
+
- **History:** the free tier covers the **last 30 days**. Full history will be
|
|
85
|
+
available on a paid plan.
|
|
86
|
+
- **Freshness:** a trading day appears the next morning (UTC), before the US
|
|
87
|
+
open. The newest day can be revised once later that day. SUI funds arrive a
|
|
88
|
+
day later.
|
|
89
|
+
|
|
90
|
+
## Options
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
CryptoETF(
|
|
94
|
+
api_key="...", # default: CRYPTOETF_API_KEY
|
|
95
|
+
timeout=15.0, # seconds per request
|
|
96
|
+
base_url="https://api.cryptoetf.today/api/v1",
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Related
|
|
101
|
+
|
|
102
|
+
- **TypeScript SDK:** [@zerosix-studio/cryptoetf](https://www.npmjs.com/package/@zerosix-studio/cryptoetf)
|
|
103
|
+
- **No-code access for AI agents:** the hosted MCP server at
|
|
104
|
+
`https://mcp.cryptoetf.today/api/mcp`
|
|
105
|
+
- **Embeddable widgets:** [cryptoetf.today/en/crypto-etf-widgets](https://cryptoetf.today/en/crypto-etf-widgets)
|
|
106
|
+
|
|
107
|
+
Not investment advice. MIT licensed.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cryptoetf"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python client for the CryptoETF API: daily net flows of US spot crypto ETFs for 16 assets (BTC, ETH, SOL, XRP, HYPE, DOGE, LINK, AVAX, HBAR, LTC, BNB, DOT, SUI, NEAR, TRX, ZEC), fund by fund, plus fund AUM, the CEFI sentiment index and coin prices. Zero dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "cryptoetf.today" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
keywords = ["crypto", "etf", "bitcoin-etf", "ethereum-etf", "etf-flows", "spot-etf", "ibit", "api", "sdk"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://cryptoetf.today/en/api"
|
|
27
|
+
Documentation = "https://docs.cryptoetf.today"
|
|
28
|
+
Source = "https://github.com/zerosix-studio/cryptoetf-sdk"
|
|
29
|
+
Issues = "https://github.com/zerosix-studio/cryptoetf-sdk/issues"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.version]
|
|
32
|
+
path = "src/cryptoetf/__init__.py"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/cryptoetf"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Official Python client for the CryptoETF API — daily net flows of US spot crypto ETFs."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
from .client import DEFAULT_BASE_URL, CryptoETF, CryptoETFError # noqa: E402
|
|
6
|
+
|
|
7
|
+
__all__ = ["CryptoETF", "CryptoETFError", "DEFAULT_BASE_URL", "__version__"]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Client for the CryptoETF public API v1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional, cast
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .types import (
|
|
15
|
+
AssetFlows,
|
|
16
|
+
AssetFundFlows,
|
|
17
|
+
CefiIndex,
|
|
18
|
+
FlowsSummary,
|
|
19
|
+
Funds,
|
|
20
|
+
Prices,
|
|
21
|
+
RateLimit,
|
|
22
|
+
Ticker,
|
|
23
|
+
WeeklyAnalytics,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.cryptoetf.today/api/v1"
|
|
27
|
+
KEY_HELP = (
|
|
28
|
+
"API key required. Get a free key at https://cryptoetf.today/en/profile "
|
|
29
|
+
"and pass CryptoETF(api_key=...) or set CRYPTOETF_API_KEY."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# (url, headers, timeout) -> (status, headers, body). Swappable for tests and proxies.
|
|
33
|
+
Transport = Callable[[str, Dict[str, str], float], "tuple[int, Dict[str, str], bytes]"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CryptoETFError(Exception):
|
|
37
|
+
"""Error returned by the API (4xx/5xx) or raised by the client (timeout, network).
|
|
38
|
+
|
|
39
|
+
``status`` is 0 for network errors and timeouts. ``code`` is a machine-readable
|
|
40
|
+
code when the API sends one (e.g. ``api_key_suspended``). ``retry_after`` is the
|
|
41
|
+
number of seconds to wait on HTTP 429.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str, status: int, code: Optional[str] = None, retry_after: Optional[int] = None):
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.message = message
|
|
47
|
+
self.status = status
|
|
48
|
+
self.code = code
|
|
49
|
+
self.retry_after = retry_after
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _urllib_transport(url: str, headers: Dict[str, str], timeout: float) -> "tuple[int, Dict[str, str], bytes]":
|
|
53
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(req, timeout=timeout) as res:
|
|
56
|
+
return res.status, {k.lower(): v for k, v in res.headers.items()}, res.read()
|
|
57
|
+
except urllib.error.HTTPError as err:
|
|
58
|
+
with err:
|
|
59
|
+
return err.code, {k.lower(): v for k, v in err.headers.items()}, err.read()
|
|
60
|
+
except (socket.timeout, TimeoutError):
|
|
61
|
+
raise CryptoETFError(f"Request timed out after {timeout} s", 0) from None
|
|
62
|
+
except urllib.error.URLError as err:
|
|
63
|
+
raise CryptoETFError(f"Network error: {err.reason}", 0) from None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Flows:
|
|
67
|
+
def __init__(self, client: "CryptoETF"):
|
|
68
|
+
self._c = client
|
|
69
|
+
|
|
70
|
+
def summary(self) -> FlowsSummary:
|
|
71
|
+
"""Latest daily net flow for every asset."""
|
|
72
|
+
return cast(FlowsSummary, self._c._get("/flows/summary"))
|
|
73
|
+
|
|
74
|
+
def asset(self, asset: str) -> AssetFlows:
|
|
75
|
+
"""Daily net flows of one asset over the history window (30 days on the free tier)."""
|
|
76
|
+
return cast(AssetFlows, self._c._get(f"/flows/{urllib.parse.quote(asset, safe='')}"))
|
|
77
|
+
|
|
78
|
+
def funds(self, asset: str) -> AssetFundFlows:
|
|
79
|
+
"""Daily net flows of one asset, fund by fund."""
|
|
80
|
+
return cast(AssetFundFlows, self._c._get(f"/flows/{urllib.parse.quote(asset, safe='')}/funds"))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CryptoETF:
|
|
84
|
+
"""Daily net flows of US spot crypto ETFs for 16 assets, fund by fund, plus
|
|
85
|
+
fund AUM, the CEFI sentiment index and coin prices.
|
|
86
|
+
|
|
87
|
+
>>> etf = CryptoETF(api_key="...")
|
|
88
|
+
>>> etf.flows.summary()["assets"][0]["symbol"]
|
|
89
|
+
'BTC'
|
|
90
|
+
|
|
91
|
+
``asset`` arguments take a code (``btc``, ``eth``, ``sol``, ``hype``…) or a full
|
|
92
|
+
name (``bitcoin``). Values are net US$ millions: positive = inflow.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
api_key: Optional[str] = None,
|
|
98
|
+
*,
|
|
99
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
100
|
+
timeout: float = 15.0,
|
|
101
|
+
transport: Optional[Transport] = None,
|
|
102
|
+
):
|
|
103
|
+
key = api_key or os.environ.get("CRYPTOETF_API_KEY")
|
|
104
|
+
if not key:
|
|
105
|
+
raise CryptoETFError(KEY_HELP, 401)
|
|
106
|
+
self._key = key
|
|
107
|
+
self._base = base_url.rstrip("/")
|
|
108
|
+
self._timeout = timeout
|
|
109
|
+
self._transport = transport or _urllib_transport
|
|
110
|
+
#: Rate-limit headers of the most recent response, or None before the first call.
|
|
111
|
+
self.rate_limit: Optional[RateLimit] = None
|
|
112
|
+
#: Daily net flows: ``summary()``, ``asset(asset)``, ``funds(asset)``.
|
|
113
|
+
self.flows = _Flows(self)
|
|
114
|
+
|
|
115
|
+
def funds(self, asset: Optional[str] = None) -> Funds:
|
|
116
|
+
"""Fund directory with assets under management; all 16 assets unless ``asset`` is given."""
|
|
117
|
+
return cast(Funds, self._get("/funds", {"asset": asset} if asset else None))
|
|
118
|
+
|
|
119
|
+
def cefi(self) -> CefiIndex:
|
|
120
|
+
"""CEFI — Crypto ETF Flow Index, a 0–100 sentiment gauge, with its recent history."""
|
|
121
|
+
return cast(CefiIndex, self._get("/index/cefi"))
|
|
122
|
+
|
|
123
|
+
def tickers(self) -> List[Ticker]:
|
|
124
|
+
"""Assets the API tracks."""
|
|
125
|
+
return cast(List[Ticker], self._get("/tickers"))
|
|
126
|
+
|
|
127
|
+
def prices(self) -> Prices:
|
|
128
|
+
"""Spot prices of the 16 coins."""
|
|
129
|
+
return cast(Prices, self._get("/prices"))
|
|
130
|
+
|
|
131
|
+
def weekly(self) -> WeeklyAnalytics:
|
|
132
|
+
"""Seven-day flow totals per asset."""
|
|
133
|
+
return cast(WeeklyAnalytics, self._get("/analytics/weekly"))
|
|
134
|
+
|
|
135
|
+
def _get(self, path: str, query: Optional[Dict[str, str]] = None) -> Any:
|
|
136
|
+
url = self._base + path
|
|
137
|
+
if query:
|
|
138
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
139
|
+
headers = {
|
|
140
|
+
"Authorization": f"Bearer {self._key}",
|
|
141
|
+
"Accept": "application/json",
|
|
142
|
+
"User-Agent": f"cryptoetf-python/{__version__}",
|
|
143
|
+
}
|
|
144
|
+
status, res_headers, raw = self._transport(url, headers, self._timeout)
|
|
145
|
+
|
|
146
|
+
if res_headers.get("x-ratelimit-limit"):
|
|
147
|
+
self.rate_limit = {
|
|
148
|
+
"limit": int(res_headers["x-ratelimit-limit"]),
|
|
149
|
+
"remaining": int(res_headers.get("x-ratelimit-remaining", 0)),
|
|
150
|
+
"reset": int(res_headers.get("x-ratelimit-reset", 0)),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
body = json.loads(raw) if raw else None
|
|
155
|
+
except ValueError:
|
|
156
|
+
body = None
|
|
157
|
+
|
|
158
|
+
if status >= 400:
|
|
159
|
+
b = body if isinstance(body, dict) else {}
|
|
160
|
+
msg = b.get("message")
|
|
161
|
+
if isinstance(msg, list):
|
|
162
|
+
msg = "; ".join(str(m) for m in msg)
|
|
163
|
+
retry = res_headers.get("retry-after")
|
|
164
|
+
raise CryptoETFError(
|
|
165
|
+
msg if isinstance(msg, str) else f"HTTP {status}",
|
|
166
|
+
status,
|
|
167
|
+
b.get("code") if isinstance(b.get("code"), str) else None,
|
|
168
|
+
int(retry) if retry and retry.isdigit() else None,
|
|
169
|
+
)
|
|
170
|
+
return body
|
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Response shapes of the CryptoETF public API v1 (https://docs.cryptoetf.today).
|
|
2
|
+
|
|
3
|
+
Flow values are net US$ millions, rounded to one decimal: positive = inflow,
|
|
4
|
+
negative = outflow. Dates are ``YYYY-MM-DD`` (UTC trading days), timestamps ISO 8601.
|
|
5
|
+
Responses are plain dicts; these TypedDicts only describe them for type checkers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import List, Literal, Optional, TypedDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FlowsSummaryItem(TypedDict):
|
|
14
|
+
symbol: str
|
|
15
|
+
asset: str
|
|
16
|
+
date: Optional[str]
|
|
17
|
+
netFlowUsdM: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FlowsSummary(TypedDict):
|
|
21
|
+
assets: List[FlowsSummaryItem]
|
|
22
|
+
updatedAt: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FlowPoint(TypedDict):
|
|
26
|
+
date: str
|
|
27
|
+
netFlowUsdM: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AssetFlows(TypedDict):
|
|
31
|
+
symbol: str
|
|
32
|
+
asset: str
|
|
33
|
+
windowDays: int
|
|
34
|
+
days: List[FlowPoint]
|
|
35
|
+
updatedAt: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FundFlow(TypedDict):
|
|
39
|
+
ticker: str
|
|
40
|
+
issuer: str
|
|
41
|
+
netFlowUsdM: Optional[float]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FundDay(TypedDict):
|
|
45
|
+
date: str
|
|
46
|
+
totalUsdM: float
|
|
47
|
+
funds: List[FundFlow]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AssetFundFlows(TypedDict):
|
|
51
|
+
symbol: str
|
|
52
|
+
asset: str
|
|
53
|
+
windowDays: int
|
|
54
|
+
days: List[FundDay]
|
|
55
|
+
updatedAt: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AssetNetAssets(TypedDict):
|
|
59
|
+
symbol: str
|
|
60
|
+
asset: str
|
|
61
|
+
totalNetAssetsUsdM: Optional[float]
|
|
62
|
+
date: Optional[str]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Fund(TypedDict):
|
|
66
|
+
ticker: str
|
|
67
|
+
name: str
|
|
68
|
+
issuer: str
|
|
69
|
+
symbol: str
|
|
70
|
+
asset: str
|
|
71
|
+
exchange: str
|
|
72
|
+
listedAt: str
|
|
73
|
+
sponsorFeePct: Optional[float]
|
|
74
|
+
netAssetsUsdM: Optional[float]
|
|
75
|
+
coinsHeld: Optional[float]
|
|
76
|
+
coinsHeldDate: Optional[str]
|
|
77
|
+
netAssetsDate: Optional[str]
|
|
78
|
+
netAssetsBasis: Optional[Literal["reported", "repriced", "estimated"]]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Funds(TypedDict):
|
|
82
|
+
assets: List[AssetNetAssets]
|
|
83
|
+
funds: List[Fund]
|
|
84
|
+
updatedAt: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CefiComponents(TypedDict):
|
|
88
|
+
flow: float
|
|
89
|
+
trend: float
|
|
90
|
+
breadth: float
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class IndexPoint(TypedDict):
|
|
94
|
+
date: str
|
|
95
|
+
value: float
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CefiIndex(TypedDict):
|
|
99
|
+
index: str
|
|
100
|
+
current: float
|
|
101
|
+
components: Optional[CefiComponents]
|
|
102
|
+
date: str
|
|
103
|
+
windowDays: int
|
|
104
|
+
history: List[IndexPoint]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Ticker(TypedDict):
|
|
108
|
+
symbol: str
|
|
109
|
+
asset: str
|
|
110
|
+
name: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Price(TypedDict):
|
|
114
|
+
symbol: str
|
|
115
|
+
priceUsd: float
|
|
116
|
+
change24hPct: float
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Prices(TypedDict):
|
|
120
|
+
prices: List[Price]
|
|
121
|
+
updatedAt: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class WeeklyAsset(TypedDict):
|
|
125
|
+
symbol: str
|
|
126
|
+
asset: str
|
|
127
|
+
netFlowUsdM: float
|
|
128
|
+
avgDailyUsdM: float
|
|
129
|
+
positiveDays: int
|
|
130
|
+
negativeDays: int
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# "from" is a Python keyword, so this one needs the functional TypedDict form.
|
|
134
|
+
WeeklyAnalytics = TypedDict(
|
|
135
|
+
"WeeklyAnalytics",
|
|
136
|
+
{"from": str, "to": str, "assets": List[WeeklyAsset]},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class RateLimit(TypedDict):
|
|
141
|
+
limit: int
|
|
142
|
+
remaining: int
|
|
143
|
+
reset: int
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from cryptoetf import CryptoETF, CryptoETFError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fake(status, body, headers=None):
|
|
9
|
+
calls = []
|
|
10
|
+
|
|
11
|
+
def transport(url, hdrs, timeout):
|
|
12
|
+
calls.append((url, hdrs))
|
|
13
|
+
return status, {k.lower(): v for k, v in (headers or {}).items()}, json.dumps(body).encode()
|
|
14
|
+
|
|
15
|
+
transport.calls = calls
|
|
16
|
+
return transport
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClientTest(unittest.TestCase):
|
|
20
|
+
def test_requires_key(self):
|
|
21
|
+
saved = os.environ.pop("CRYPTOETF_API_KEY", None)
|
|
22
|
+
try:
|
|
23
|
+
with self.assertRaises(CryptoETFError) as ctx:
|
|
24
|
+
CryptoETF()
|
|
25
|
+
self.assertIn("cryptoetf.today/en/profile", ctx.exception.message)
|
|
26
|
+
finally:
|
|
27
|
+
if saved:
|
|
28
|
+
os.environ["CRYPTOETF_API_KEY"] = saved
|
|
29
|
+
|
|
30
|
+
def test_paths_headers_rate_limit(self):
|
|
31
|
+
t = fake(200, {"symbol": "BTC", "days": []}, {
|
|
32
|
+
"X-RateLimit-Limit": "120", "X-RateLimit-Remaining": "119", "X-RateLimit-Reset": "1700000000",
|
|
33
|
+
})
|
|
34
|
+
etf = CryptoETF("k1", base_url="https://example.test/api/v1/", transport=t)
|
|
35
|
+
self.assertEqual(etf.flows.funds("btc")["symbol"], "BTC")
|
|
36
|
+
self.assertEqual(t.calls[0][0], "https://example.test/api/v1/flows/btc/funds")
|
|
37
|
+
self.assertEqual(t.calls[0][1]["Authorization"], "Bearer k1")
|
|
38
|
+
self.assertEqual(etf.rate_limit, {"limit": 120, "remaining": 119, "reset": 1700000000})
|
|
39
|
+
etf.funds("sol")
|
|
40
|
+
self.assertEqual(t.calls[1][0], "https://example.test/api/v1/funds?asset=sol")
|
|
41
|
+
etf.funds()
|
|
42
|
+
self.assertEqual(t.calls[2][0], "https://example.test/api/v1/funds")
|
|
43
|
+
|
|
44
|
+
def test_errors(self):
|
|
45
|
+
etf = CryptoETF("k", transport=fake(429, {"statusCode": 429, "message": "Slow down"}, {"Retry-After": "12"}))
|
|
46
|
+
with self.assertRaises(CryptoETFError) as ctx:
|
|
47
|
+
etf.prices()
|
|
48
|
+
self.assertEqual((ctx.exception.status, ctx.exception.retry_after, ctx.exception.message), (429, 12, "Slow down"))
|
|
49
|
+
|
|
50
|
+
etf2 = CryptoETF("k", transport=fake(403, {"statusCode": 403, "code": "api_key_suspended", "message": "Suspended"}))
|
|
51
|
+
with self.assertRaises(CryptoETFError) as ctx:
|
|
52
|
+
etf2.cefi()
|
|
53
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code), (403, "api_key_suspended"))
|
|
54
|
+
|
|
55
|
+
@unittest.skipIf(os.environ.get("CRYPTOETF_OFFLINE") == "1", "offline")
|
|
56
|
+
def test_live_bad_key(self):
|
|
57
|
+
with self.assertRaises(CryptoETFError) as ctx:
|
|
58
|
+
CryptoETF("invalid-key-for-sdk-test").flows.summary()
|
|
59
|
+
self.assertEqual(ctx.exception.status, 401)
|
|
60
|
+
|
|
61
|
+
@unittest.skipUnless(os.environ.get("CRYPTOETF_API_KEY"), "no CRYPTOETF_API_KEY")
|
|
62
|
+
def test_live_real_key(self):
|
|
63
|
+
etf = CryptoETF()
|
|
64
|
+
self.assertGreaterEqual(len(etf.flows.summary()["assets"]), 16)
|
|
65
|
+
self.assertTrue(any(t["symbol"] == "BTC" for t in etf.tickers()))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
unittest.main()
|