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.
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ .venv/
@@ -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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/cryptoetf?color=3775a9&logo=pypi&logoColor=white)](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
+ [![PyPI](https://img.shields.io/pypi/v/cryptoetf?color=3775a9&logo=pypi&logoColor=white)](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()