bitculator 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bitculator/__init__.py +37 -0
- bitculator/_http.py +214 -0
- bitculator/_version.py +3 -0
- bitculator/client.py +89 -0
- bitculator/errors.py +119 -0
- bitculator/pagination.py +59 -0
- bitculator/py.typed +0 -0
- bitculator/resources/__init__.py +24 -0
- bitculator/resources/_base.py +18 -0
- bitculator/resources/account.py +81 -0
- bitculator/resources/coins.py +80 -0
- bitculator/resources/editorial.py +43 -0
- bitculator/resources/indicators.py +100 -0
- bitculator/resources/market.py +79 -0
- bitculator/resources/markets.py +90 -0
- bitculator-1.0.0.dist-info/METADATA +144 -0
- bitculator-1.0.0.dist-info/RECORD +19 -0
- bitculator-1.0.0.dist-info/WHEEL +4 -0
- bitculator-1.0.0.dist-info/licenses/LICENSE +21 -0
bitculator/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Official Python SDK for the Bitculator Data API."""
|
|
2
|
+
|
|
3
|
+
from ._http import Quota
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .client import Bitculator
|
|
6
|
+
from .errors import (
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
APIDecodeError,
|
|
9
|
+
APIError,
|
|
10
|
+
APITimeoutError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
BitculatorError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ServerError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
)
|
|
19
|
+
from .pagination import Page
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Bitculator",
|
|
23
|
+
"Page",
|
|
24
|
+
"Quota",
|
|
25
|
+
"BitculatorError",
|
|
26
|
+
"APIError",
|
|
27
|
+
"APITimeoutError",
|
|
28
|
+
"APIConnectionError",
|
|
29
|
+
"APIDecodeError",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"PermissionDeniedError",
|
|
32
|
+
"NotFoundError",
|
|
33
|
+
"ValidationError",
|
|
34
|
+
"RateLimitError",
|
|
35
|
+
"ServerError",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
bitculator/_http.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The DRY transport core. Every request in the SDK flows through
|
|
3
|
+
``HttpClient._request``: URL + query building, auth headers, JSON
|
|
4
|
+
(de)serialization, the response envelope, quota capture, retries with backoff,
|
|
5
|
+
timeouts, and error mapping all live here — nowhere else. Uses only the standard
|
|
6
|
+
library (``urllib``) so the SDK has zero runtime dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import decimal
|
|
12
|
+
import http.client
|
|
13
|
+
import json
|
|
14
|
+
import random
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Callable, Optional
|
|
21
|
+
|
|
22
|
+
from .errors import APIConnectionError, APIDecodeError, APIError, APITimeoutError, BitculatorError
|
|
23
|
+
from .pagination import Page
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Quota:
|
|
28
|
+
"""Quota headers (``X-Quota-*``) from the most recent response."""
|
|
29
|
+
|
|
30
|
+
limit: Optional[int]
|
|
31
|
+
remaining: Optional[int]
|
|
32
|
+
reset: Optional[int]
|
|
33
|
+
raw: dict
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HttpClient:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
api_key: str,
|
|
41
|
+
base_url: str,
|
|
42
|
+
timeout: float,
|
|
43
|
+
max_retries: int,
|
|
44
|
+
user_agent: str,
|
|
45
|
+
on_quota: Callable[[Quota], None],
|
|
46
|
+
opener: Optional[urllib.request.OpenerDirector] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._api_key = api_key
|
|
49
|
+
self._base_url = base_url
|
|
50
|
+
self._timeout = timeout
|
|
51
|
+
self._max_retries = max_retries
|
|
52
|
+
self._user_agent = user_agent
|
|
53
|
+
self._on_quota = on_quota
|
|
54
|
+
self._opener = opener or urllib.request.build_opener()
|
|
55
|
+
|
|
56
|
+
def get(self, path: str, params: Optional[dict] = None) -> Any:
|
|
57
|
+
return _unwrap(self._request("GET", path, params=params))
|
|
58
|
+
|
|
59
|
+
def post(self, path: str, body: Any = None, params: Optional[dict] = None) -> Any:
|
|
60
|
+
return _unwrap(self._request("POST", path, params=params, body=body))
|
|
61
|
+
|
|
62
|
+
def delete(self, path: str, params: Optional[dict] = None) -> Any:
|
|
63
|
+
return _unwrap(self._request("DELETE", path, params=params))
|
|
64
|
+
|
|
65
|
+
def get_page(self, path: str, params: Optional[dict] = None) -> Page:
|
|
66
|
+
envelope = self._request("GET", path, params=params)
|
|
67
|
+
return Page(self, path, params or {}, envelope)
|
|
68
|
+
|
|
69
|
+
def _request(self, method: str, path: str, params: Optional[dict] = None, body: Any = None) -> Any:
|
|
70
|
+
url = self._base_url + path + _query(params)
|
|
71
|
+
headers = {
|
|
72
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
73
|
+
"Accept": "application/json",
|
|
74
|
+
"User-Agent": self._user_agent,
|
|
75
|
+
}
|
|
76
|
+
data: Optional[bytes] = None
|
|
77
|
+
if body is not None:
|
|
78
|
+
try:
|
|
79
|
+
data = json.dumps(_snake_keys(body)).encode("utf-8")
|
|
80
|
+
except (TypeError, ValueError) as exc:
|
|
81
|
+
raise BitculatorError(f"Failed to encode request body: {exc}") from exc
|
|
82
|
+
headers["Content-Type"] = "application/json"
|
|
83
|
+
|
|
84
|
+
# Idempotency gate: GET/DELETE are safe to auto-retry; POST is NOT, so a
|
|
85
|
+
# POST the server may already have processed is never re-sent on a 5xx or
|
|
86
|
+
# a network/timeout error. A 429 means the server rejected it before
|
|
87
|
+
# processing, so it stays retryable for every method.
|
|
88
|
+
is_idempotent = method in ("GET", "DELETE")
|
|
89
|
+
|
|
90
|
+
attempt = 0
|
|
91
|
+
while True:
|
|
92
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
93
|
+
try:
|
|
94
|
+
with self._opener.open(request, timeout=self._timeout) as response:
|
|
95
|
+
self._on_quota(_parse_quota(response.headers))
|
|
96
|
+
raw = response.read()
|
|
97
|
+
if not raw:
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
return json.loads(raw)
|
|
101
|
+
except (ValueError, TypeError) as exc:
|
|
102
|
+
raise APIDecodeError(f"Failed to decode response from {path}: {exc}") from exc
|
|
103
|
+
except urllib.error.HTTPError as exc:
|
|
104
|
+
self._on_quota(_parse_quota(exc.headers))
|
|
105
|
+
raw = exc.read()
|
|
106
|
+
payload = _safe_json(raw)
|
|
107
|
+
status = exc.code
|
|
108
|
+
retryable = status == 429 or (status >= 500 and is_idempotent)
|
|
109
|
+
if retryable and attempt < self._max_retries:
|
|
110
|
+
time.sleep(_retry_delay(exc.headers, attempt))
|
|
111
|
+
attempt += 1
|
|
112
|
+
continue
|
|
113
|
+
raise APIError.from_response(status, payload, exc.headers) from None
|
|
114
|
+
except (urllib.error.URLError, http.client.HTTPException, TimeoutError, OSError) as exc:
|
|
115
|
+
if is_idempotent and attempt < self._max_retries:
|
|
116
|
+
time.sleep(_backoff(attempt))
|
|
117
|
+
attempt += 1
|
|
118
|
+
continue
|
|
119
|
+
reason = getattr(exc, "reason", exc)
|
|
120
|
+
if isinstance(reason, TimeoutError) or "timed out" in str(reason).lower():
|
|
121
|
+
raise APITimeoutError(f"Request to {path} timed out after {self._timeout}s") from exc
|
|
122
|
+
raise APIConnectionError(f"Network error requesting {path}: {reason}") from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── helpers (all pure; the single place these concerns are implemented) ──────
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _unwrap(envelope: Any) -> Any:
|
|
129
|
+
"""Unwrap the ``{"data": ...}`` envelope; pass through bodies with no data key."""
|
|
130
|
+
if isinstance(envelope, dict) and "data" in envelope:
|
|
131
|
+
return envelope["data"]
|
|
132
|
+
return envelope
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _query(params: Optional[dict]) -> str:
|
|
136
|
+
"""Serialize params: drop ``None``, lists→CSV, bools→true/false, trailing ``_`` stripped (``from_``→``from``)."""
|
|
137
|
+
if not params:
|
|
138
|
+
return ""
|
|
139
|
+
pairs = []
|
|
140
|
+
for key, value in params.items():
|
|
141
|
+
if value is None:
|
|
142
|
+
continue
|
|
143
|
+
name = key[:-1] if key.endswith("_") else key
|
|
144
|
+
if isinstance(value, (list, tuple)):
|
|
145
|
+
if not value:
|
|
146
|
+
continue
|
|
147
|
+
pairs.append((name, ",".join(str(item) for item in value)))
|
|
148
|
+
elif isinstance(value, bool):
|
|
149
|
+
pairs.append((name, "true" if value else "false"))
|
|
150
|
+
elif isinstance(value, float):
|
|
151
|
+
# Plain-decimal form (0.0000001, not 1e-07) — keeps the wire format
|
|
152
|
+
# identical across the SDK family.
|
|
153
|
+
pairs.append((name, format(decimal.Decimal(repr(value)), "f")))
|
|
154
|
+
else:
|
|
155
|
+
pairs.append((name, str(value)))
|
|
156
|
+
return "?" + urllib.parse.urlencode(pairs) if pairs else ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _snake_keys(body: Any) -> Any:
|
|
160
|
+
"""Strip a trailing underscore from body keys (``from_``→``from``); leave the rest as-is."""
|
|
161
|
+
if not isinstance(body, dict):
|
|
162
|
+
return body
|
|
163
|
+
return {(key[:-1] if key.endswith("_") else key): value for key, value in body.items()}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_quota(headers: Any) -> Quota:
|
|
167
|
+
raw = {}
|
|
168
|
+
if headers is not None:
|
|
169
|
+
for key, value in headers.items():
|
|
170
|
+
lowered = key.lower()
|
|
171
|
+
if lowered.startswith("x-quota-"):
|
|
172
|
+
raw[lowered] = value
|
|
173
|
+
|
|
174
|
+
def num(name: str) -> Optional[int]:
|
|
175
|
+
value = raw.get(name)
|
|
176
|
+
if value in (None, ""):
|
|
177
|
+
return None
|
|
178
|
+
try:
|
|
179
|
+
return int(value)
|
|
180
|
+
except (TypeError, ValueError):
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
return Quota(
|
|
184
|
+
limit=num("x-quota-limit"),
|
|
185
|
+
remaining=num("x-quota-remaining"),
|
|
186
|
+
reset=num("x-quota-reset"),
|
|
187
|
+
raw=raw,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _retry_delay(headers: Any, attempt: int) -> float:
|
|
192
|
+
retry_after = headers.get("retry-after") if headers is not None else None
|
|
193
|
+
try:
|
|
194
|
+
seconds = int(retry_after)
|
|
195
|
+
if seconds > 0:
|
|
196
|
+
# Cap at 60s: a misbehaving proxy must not block a synchronous call for an hour.
|
|
197
|
+
return float(min(seconds, 60))
|
|
198
|
+
except (TypeError, ValueError):
|
|
199
|
+
pass
|
|
200
|
+
return _backoff(attempt)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _backoff(attempt: int) -> float:
|
|
204
|
+
"""Exponential backoff with jitter, capped at 8s."""
|
|
205
|
+
return min(8.0, 0.25 * (2 ** attempt)) + random.uniform(0.0, 0.1)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _safe_json(raw: bytes) -> Any:
|
|
209
|
+
if not raw:
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
return json.loads(raw)
|
|
213
|
+
except (ValueError, TypeError):
|
|
214
|
+
return None
|
bitculator/_version.py
ADDED
bitculator/client.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import urllib.request
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ._http import HttpClient, Quota
|
|
7
|
+
from ._version import __version__
|
|
8
|
+
from .errors import BitculatorError
|
|
9
|
+
from .resources import (
|
|
10
|
+
AlarmsResource,
|
|
11
|
+
CalculatorsResource,
|
|
12
|
+
CoinsResource,
|
|
13
|
+
ConversionResource,
|
|
14
|
+
EditorialResource,
|
|
15
|
+
ExchangesResource,
|
|
16
|
+
GlobalMarketResource,
|
|
17
|
+
IndicatorsResource,
|
|
18
|
+
LiquidationsResource,
|
|
19
|
+
MarketsResource,
|
|
20
|
+
MetaResource,
|
|
21
|
+
PricesResource,
|
|
22
|
+
SentimentResource,
|
|
23
|
+
WalletsResource,
|
|
24
|
+
WebhooksResource,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE_URL = "https://bitculator.com"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Bitculator:
|
|
31
|
+
"""The Bitculator Data API client.
|
|
32
|
+
|
|
33
|
+
>>> client = Bitculator(api_key="...")
|
|
34
|
+
>>> bitcoin = client.coins.get("bitcoin")
|
|
35
|
+
|
|
36
|
+
Each API group is an attribute on the client: ``client.coins``,
|
|
37
|
+
``client.prices``, ``client.exchanges``, and so on.
|
|
38
|
+
|
|
39
|
+
Pass ``opener`` (a ``urllib.request.OpenerDirector``) to inject for
|
|
40
|
+
proxies/custom handlers; every request then goes through it.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: str,
|
|
46
|
+
*,
|
|
47
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
max_retries: int = 2,
|
|
50
|
+
user_agent: Optional[str] = None,
|
|
51
|
+
opener: Optional[urllib.request.OpenerDirector] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
if not api_key:
|
|
54
|
+
raise BitculatorError("A Data API key is required: Bitculator(api_key=...)")
|
|
55
|
+
|
|
56
|
+
self._quota: Optional[Quota] = None
|
|
57
|
+
http = HttpClient(
|
|
58
|
+
api_key=api_key,
|
|
59
|
+
base_url=base_url.rstrip("/"),
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
max_retries=max_retries,
|
|
62
|
+
user_agent=user_agent or f"bitculator-python/{__version__}",
|
|
63
|
+
on_quota=self._set_quota,
|
|
64
|
+
opener=opener,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.coins = CoinsResource(http)
|
|
68
|
+
self.prices = PricesResource(http)
|
|
69
|
+
self.markets = MarketsResource(http)
|
|
70
|
+
self.exchanges = ExchangesResource(http)
|
|
71
|
+
self.wallets = WalletsResource(http)
|
|
72
|
+
self.global_market = GlobalMarketResource(http)
|
|
73
|
+
self.sentiment = SentimentResource(http)
|
|
74
|
+
self.indicators = IndicatorsResource(http)
|
|
75
|
+
self.liquidations = LiquidationsResource(http)
|
|
76
|
+
self.conversion = ConversionResource(http)
|
|
77
|
+
self.calculators = CalculatorsResource(http)
|
|
78
|
+
self.editorial = EditorialResource(http)
|
|
79
|
+
self.alarms = AlarmsResource(http)
|
|
80
|
+
self.webhooks = WebhooksResource(http)
|
|
81
|
+
self.meta = MetaResource(http)
|
|
82
|
+
|
|
83
|
+
def _set_quota(self, quota: Quota) -> None:
|
|
84
|
+
self._quota = quota
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def quota(self) -> Optional[Quota]:
|
|
88
|
+
"""Quota (``X-Quota-*``) from the most recent response, or ``None`` before the first call."""
|
|
89
|
+
return self._quota
|
bitculator/errors.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception hierarchy. Everything the SDK raises derives from ``BitculatorError``,
|
|
3
|
+
so a single ``except BitculatorError`` covers it all. HTTP failures raise a
|
|
4
|
+
status-specific ``APIError`` subclass carrying the API's
|
|
5
|
+
``{"error": {"code", "message", "details"}}`` envelope.
|
|
6
|
+
|
|
7
|
+
(Note: 403 is ``PermissionDeniedError`` and timeouts are ``APITimeoutError`` to
|
|
8
|
+
avoid shadowing the built-in ``PermissionError`` / ``TimeoutError``.)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BitculatorError(Exception):
|
|
17
|
+
"""Base class for every error raised by the SDK."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APITimeoutError(BitculatorError):
|
|
21
|
+
"""The request exceeded the configured timeout (after retries)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class APIConnectionError(BitculatorError):
|
|
25
|
+
"""A network-level failure prevented the request from completing."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class APIDecodeError(BitculatorError):
|
|
29
|
+
"""A 2xx body could not be parsed."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class APIError(BitculatorError):
|
|
33
|
+
"""A non-2xx response. Subclasses map to specific status codes."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, status: int, body: Optional[dict], request_id: Optional[str]) -> None:
|
|
36
|
+
err = body or {}
|
|
37
|
+
self.status = status
|
|
38
|
+
self.code: Optional[str] = err.get("code")
|
|
39
|
+
self.details: Any = err.get("details")
|
|
40
|
+
self.request_id = request_id
|
|
41
|
+
self.message: str = err.get("message") or f"HTTP {status}"
|
|
42
|
+
super().__init__(self.message)
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def from_response(status: int, json_body: Any, headers: Any) -> "APIError":
|
|
46
|
+
"""Build the most specific error subclass for a failed response."""
|
|
47
|
+
body = _error_body(json_body)
|
|
48
|
+
request_id = headers.get("x-request-id") if headers is not None else None
|
|
49
|
+
if status == 429:
|
|
50
|
+
return RateLimitError(status, body, request_id, _to_int(_header(headers, "retry-after")))
|
|
51
|
+
cls = _STATUS_MAP.get(status)
|
|
52
|
+
if cls is not None:
|
|
53
|
+
return cls(status, body, request_id)
|
|
54
|
+
if status >= 500:
|
|
55
|
+
return ServerError(status, body, request_id)
|
|
56
|
+
return APIError(status, body, request_id)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AuthenticationError(APIError):
|
|
60
|
+
"""401 — missing, malformed, or invalid API key."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PermissionDeniedError(APIError):
|
|
64
|
+
"""403 — the key's plan does not grant access to this endpoint."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NotFoundError(APIError):
|
|
68
|
+
"""404 — the requested resource does not exist."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ValidationError(APIError):
|
|
72
|
+
"""422 — invalid parameters (see ``details``)."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ServerError(APIError):
|
|
76
|
+
"""5xx — an error on the API side."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RateLimitError(APIError):
|
|
80
|
+
"""429 — quota or rate limit exceeded."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
status: int,
|
|
85
|
+
body: Optional[dict],
|
|
86
|
+
request_id: Optional[str],
|
|
87
|
+
retry_after: Optional[int] = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
super().__init__(status, body, request_id)
|
|
90
|
+
self.retry_after = retry_after
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_STATUS_MAP = {
|
|
94
|
+
401: AuthenticationError,
|
|
95
|
+
403: PermissionDeniedError,
|
|
96
|
+
404: NotFoundError,
|
|
97
|
+
422: ValidationError,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _error_body(json_body: Any) -> Optional[dict]:
|
|
102
|
+
if isinstance(json_body, dict):
|
|
103
|
+
err = json_body.get("error")
|
|
104
|
+
if isinstance(err, dict):
|
|
105
|
+
return err
|
|
106
|
+
if isinstance(json_body.get("message"), str):
|
|
107
|
+
return {"message": json_body["message"]}
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _header(headers: Any, name: str) -> Any:
|
|
112
|
+
return headers.get(name) if headers is not None else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _to_int(value: Any) -> Optional[int]:
|
|
116
|
+
try:
|
|
117
|
+
return int(value)
|
|
118
|
+
except (TypeError, ValueError):
|
|
119
|
+
return None
|
bitculator/pagination.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A single page of a paginated list, plus lazy forward navigation.
|
|
3
|
+
|
|
4
|
+
``page.data`` is the current page's rows. Iterating a ``Page`` (or calling
|
|
5
|
+
``auto_paging_iter()``) transparently walks every following page.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Iterator, List, Optional
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ._http import HttpClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Page:
|
|
17
|
+
def __init__(self, http: "HttpClient", path: str, params: dict, envelope: Any) -> None:
|
|
18
|
+
env = envelope if isinstance(envelope, dict) else {}
|
|
19
|
+
#: Rows on this page.
|
|
20
|
+
self.data: List[Any] = env.get("data", []) or []
|
|
21
|
+
#: Laravel paginator metadata (current_page, last_page, total, ...).
|
|
22
|
+
self.meta: dict = env.get("meta", {}) or {}
|
|
23
|
+
#: Page navigation links.
|
|
24
|
+
self.links: dict = env.get("links", {}) or {}
|
|
25
|
+
self._http = http
|
|
26
|
+
self._path = path
|
|
27
|
+
self._params = params
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def has_more(self) -> bool:
|
|
31
|
+
"""True if at least one more page exists."""
|
|
32
|
+
if self.links.get("next"):
|
|
33
|
+
return True
|
|
34
|
+
current, last = self.meta.get("current_page"), self.meta.get("last_page")
|
|
35
|
+
return current is not None and last is not None and current < last
|
|
36
|
+
|
|
37
|
+
def next_page(self) -> Optional["Page"]:
|
|
38
|
+
"""Fetch the next page, or ``None`` if this is the last one."""
|
|
39
|
+
if not self.has_more:
|
|
40
|
+
return None
|
|
41
|
+
next_number = (self.meta.get("current_page") or 1) + 1
|
|
42
|
+
return self._http.get_page(self._path, {**self._params, "page": next_number})
|
|
43
|
+
|
|
44
|
+
def auto_paging_iter(self) -> Iterator[Any]:
|
|
45
|
+
"""Yield every row across this and all following pages, fetching lazily."""
|
|
46
|
+
page: Optional[Page] = self
|
|
47
|
+
while page is not None:
|
|
48
|
+
yield from page.data
|
|
49
|
+
page = page.next_page()
|
|
50
|
+
|
|
51
|
+
def __iter__(self) -> Iterator[Any]:
|
|
52
|
+
"""Iterating a page walks all pages (use ``.data`` for just this page)."""
|
|
53
|
+
return self.auto_paging_iter()
|
|
54
|
+
|
|
55
|
+
def __len__(self) -> int:
|
|
56
|
+
return len(self.data)
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
return f"<Page rows={len(self.data)} page={self.meta.get('current_page')}/{self.meta.get('last_page')}>"
|
bitculator/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .account import AlarmsResource, MetaResource, WebhooksResource
|
|
2
|
+
from .coins import CoinsResource, ConversionResource, PricesResource
|
|
3
|
+
from .editorial import EditorialResource
|
|
4
|
+
from .indicators import CalculatorsResource, IndicatorsResource
|
|
5
|
+
from .market import GlobalMarketResource, LiquidationsResource, SentimentResource
|
|
6
|
+
from .markets import ExchangesResource, MarketsResource, WalletsResource
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AlarmsResource",
|
|
10
|
+
"CalculatorsResource",
|
|
11
|
+
"CoinsResource",
|
|
12
|
+
"ConversionResource",
|
|
13
|
+
"EditorialResource",
|
|
14
|
+
"ExchangesResource",
|
|
15
|
+
"GlobalMarketResource",
|
|
16
|
+
"IndicatorsResource",
|
|
17
|
+
"LiquidationsResource",
|
|
18
|
+
"MarketsResource",
|
|
19
|
+
"MetaResource",
|
|
20
|
+
"PricesResource",
|
|
21
|
+
"SentimentResource",
|
|
22
|
+
"WalletsResource",
|
|
23
|
+
"WebhooksResource",
|
|
24
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import urllib.parse
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .._http import HttpClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Resource:
|
|
10
|
+
"""Shared base for every resource group: holds the transport and encodes path segments."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, http: HttpClient) -> None:
|
|
13
|
+
self._http = http
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def _enc(value: Any) -> str:
|
|
17
|
+
"""Percent-encode a dynamic path segment (slug or id) so it is URL-safe."""
|
|
18
|
+
return urllib.parse.quote(str(value), safe="")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Union
|
|
4
|
+
|
|
5
|
+
from ..pagination import Page
|
|
6
|
+
from ._base import Resource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AlarmsResource(Resource):
|
|
10
|
+
"""Price alarms owned by the API key."""
|
|
11
|
+
|
|
12
|
+
def list(self, **params: Any) -> Page:
|
|
13
|
+
"""List your alarms (paginated). Params: page, per_page, status, direction, notification."""
|
|
14
|
+
return self._http.get_page("/api/v1/alarms", params)
|
|
15
|
+
|
|
16
|
+
def create(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
name: str,
|
|
20
|
+
coin: str,
|
|
21
|
+
metric: str,
|
|
22
|
+
direction: str,
|
|
23
|
+
target: float,
|
|
24
|
+
notification: str,
|
|
25
|
+
) -> Any:
|
|
26
|
+
"""Create an alarm."""
|
|
27
|
+
return self._http.post(
|
|
28
|
+
"/api/v1/alarms",
|
|
29
|
+
{
|
|
30
|
+
"name": name,
|
|
31
|
+
"coin": coin,
|
|
32
|
+
"metric": metric,
|
|
33
|
+
"direction": direction,
|
|
34
|
+
"target": target,
|
|
35
|
+
"notification": notification,
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def delete(self, id: Union[str, int]) -> Any:
|
|
40
|
+
"""Delete an alarm by id."""
|
|
41
|
+
return self._http.delete(f"/api/v1/alarms/{self._enc(id)}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WebhooksResource(Resource):
|
|
45
|
+
"""Webhook endpoints and their delivery log."""
|
|
46
|
+
|
|
47
|
+
def list(self) -> Any:
|
|
48
|
+
"""List your webhook endpoints."""
|
|
49
|
+
return self._http.get("/api/v1/webhooks")
|
|
50
|
+
|
|
51
|
+
def create(self, *, url: str, events: List[str]) -> Any:
|
|
52
|
+
"""Register a webhook endpoint for a set of events."""
|
|
53
|
+
return self._http.post("/api/v1/webhooks", {"url": url, "events": events})
|
|
54
|
+
|
|
55
|
+
def delete(self, id: Union[str, int]) -> Any:
|
|
56
|
+
"""Delete a webhook endpoint by id."""
|
|
57
|
+
return self._http.delete(f"/api/v1/webhooks/{self._enc(id)}")
|
|
58
|
+
|
|
59
|
+
def test(self, id: Union[str, int]) -> Any:
|
|
60
|
+
"""Send a test event to a webhook endpoint."""
|
|
61
|
+
return self._http.post(f"/api/v1/webhooks/{self._enc(id)}/test")
|
|
62
|
+
|
|
63
|
+
def deliveries(self, id: Union[str, int], **params: Any) -> Page:
|
|
64
|
+
"""Delivery log for a webhook endpoint (paginated). Params: page, per_page."""
|
|
65
|
+
return self._http.get_page(f"/api/v1/webhooks/{self._enc(id)}/deliveries", params)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class MetaResource(Resource):
|
|
69
|
+
"""Service metadata: spec, health, and key usage."""
|
|
70
|
+
|
|
71
|
+
def openapi(self) -> Any:
|
|
72
|
+
"""The raw OpenAPI specification for the API."""
|
|
73
|
+
return self._http.get("/api/v1/openapi.json")
|
|
74
|
+
|
|
75
|
+
def ping(self) -> Any:
|
|
76
|
+
"""Health check."""
|
|
77
|
+
return self._http.get("/api/v1/ping")
|
|
78
|
+
|
|
79
|
+
def usage(self) -> Any:
|
|
80
|
+
"""Current key usage and quota."""
|
|
81
|
+
return self._http.get("/api/v1/usage")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..pagination import Page
|
|
6
|
+
from ._base import Resource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CoinsResource(Resource):
|
|
10
|
+
"""Coins & tokens: listings, movers, detail, and history."""
|
|
11
|
+
|
|
12
|
+
def list(self, **params: Any) -> Page:
|
|
13
|
+
"""List coins (paginated). Params: page, per_page, type, status, search,
|
|
14
|
+
min_price, max_price, min_marketcap, max_marketcap, min_volume, max_volume,
|
|
15
|
+
ids, slugs, symbols, sort, interval."""
|
|
16
|
+
return self._http.get_page("/api/v1/coins", params)
|
|
17
|
+
|
|
18
|
+
def recently_added(self, **params: Any) -> Page:
|
|
19
|
+
"""Recently added coins (paginated). Same params as :meth:`list`."""
|
|
20
|
+
return self._http.get_page("/api/v1/coins/recently-added", params)
|
|
21
|
+
|
|
22
|
+
def gainers(self, **params: Any) -> Page:
|
|
23
|
+
"""Top gainers (paginated). Same params as :meth:`list`."""
|
|
24
|
+
return self._http.get_page("/api/v1/coins/gainers", params)
|
|
25
|
+
|
|
26
|
+
def losers(self, **params: Any) -> Page:
|
|
27
|
+
"""Top losers (paginated). Same params as :meth:`list`."""
|
|
28
|
+
return self._http.get_page("/api/v1/coins/losers", params)
|
|
29
|
+
|
|
30
|
+
def trending(self, **params: Any) -> Any:
|
|
31
|
+
"""Trending coins. Params: limit."""
|
|
32
|
+
return self._http.get("/api/v1/coins/trending", params)
|
|
33
|
+
|
|
34
|
+
def get(self, slug: str, **params: Any) -> Any:
|
|
35
|
+
"""Get a single coin by slug. Params: locale."""
|
|
36
|
+
return self._http.get(f"/api/v1/coins/{self._enc(slug)}", params)
|
|
37
|
+
|
|
38
|
+
def history(self, slug: str, **params: Any) -> Any:
|
|
39
|
+
"""OHLC candle history. Params: interval, start, end, limit."""
|
|
40
|
+
return self._http.get(f"/api/v1/coins/{self._enc(slug)}/history", params)
|
|
41
|
+
|
|
42
|
+
def marketcap_history(self, slug: str, **params: Any) -> Any:
|
|
43
|
+
"""Marketcap history. Params: interval, start, end, limit."""
|
|
44
|
+
return self._http.get(f"/api/v1/coins/{self._enc(slug)}/marketcap-history", params)
|
|
45
|
+
|
|
46
|
+
def sparkline(self, slug: str, **params: Any) -> Any:
|
|
47
|
+
"""Price sparkline. Params: period."""
|
|
48
|
+
return self._http.get(f"/api/v1/coins/{self._enc(slug)}/sparkline", params)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PricesResource(Resource):
|
|
52
|
+
"""Lightweight price lookups."""
|
|
53
|
+
|
|
54
|
+
def list(self, **params: Any) -> Any:
|
|
55
|
+
"""Get prices for many coins at once. Params: ids, slugs, symbols, convert."""
|
|
56
|
+
return self._http.get("/api/v1/prices", params)
|
|
57
|
+
|
|
58
|
+
def get(self, slug: str, **params: Any) -> Any:
|
|
59
|
+
"""Get the price of a single coin. Params: convert."""
|
|
60
|
+
return self._http.get(f"/api/v1/prices/{self._enc(slug)}", params)
|
|
61
|
+
|
|
62
|
+
def historical(self, **params: Any) -> Any:
|
|
63
|
+
"""Historical price of a coin on a date. Params: slug, date."""
|
|
64
|
+
return self._http.get("/api/v1/historical-price", params)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ConversionResource(Resource):
|
|
68
|
+
"""Asset conversion and reference rates."""
|
|
69
|
+
|
|
70
|
+
def convert(self, **params: Any) -> Any:
|
|
71
|
+
"""Convert an amount between two assets. Params: from_ (-> ``from``), to, amount."""
|
|
72
|
+
return self._http.get("/api/v1/convert", params)
|
|
73
|
+
|
|
74
|
+
def fiats(self) -> Any:
|
|
75
|
+
"""List supported fiat currencies."""
|
|
76
|
+
return self._http.get("/api/v1/fiats")
|
|
77
|
+
|
|
78
|
+
def rates(self) -> Any:
|
|
79
|
+
"""List conversion rates."""
|
|
80
|
+
return self._http.get("/api/v1/rates")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Union
|
|
4
|
+
|
|
5
|
+
from ..pagination import Page
|
|
6
|
+
from ._base import Resource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EditorialResource(Resource):
|
|
10
|
+
"""Editorial content: articles, videos, and insights."""
|
|
11
|
+
|
|
12
|
+
def coin_videos(self, slug: str, **params: Any) -> Page:
|
|
13
|
+
"""Videos for a coin (paginated). Params: page, per_page, type, search."""
|
|
14
|
+
return self._http.get_page(f"/api/v1/coins/{self._enc(slug)}/videos", params)
|
|
15
|
+
|
|
16
|
+
def coin_insights(self, slug: str, **params: Any) -> Any:
|
|
17
|
+
"""Insight timeline for a coin. Params: locale, offset, limit."""
|
|
18
|
+
return self._http.get(f"/api/v1/coins/{self._enc(slug)}/insights", params)
|
|
19
|
+
|
|
20
|
+
def articles(self, **params: Any) -> Page:
|
|
21
|
+
"""List articles (paginated). Params: page, per_page, locale, tag, coin,
|
|
22
|
+
exchange, wallet, search."""
|
|
23
|
+
return self._http.get_page("/api/v1/articles", params)
|
|
24
|
+
|
|
25
|
+
def article(self, slug: str, **params: Any) -> Any:
|
|
26
|
+
"""Get a single article by slug. Params: locale."""
|
|
27
|
+
return self._http.get(f"/api/v1/articles/{self._enc(slug)}", params)
|
|
28
|
+
|
|
29
|
+
def submit_article_feedback(self, slug: str, *, helpful: bool) -> Any:
|
|
30
|
+
"""Submit helpful/unhelpful feedback on an article."""
|
|
31
|
+
return self._http.post(f"/api/v1/articles/{self._enc(slug)}/feedback", {"helpful": helpful})
|
|
32
|
+
|
|
33
|
+
def video(self, id: Union[str, int]) -> Any:
|
|
34
|
+
"""Get a single video by id."""
|
|
35
|
+
return self._http.get(f"/api/v1/videos/{self._enc(id)}")
|
|
36
|
+
|
|
37
|
+
def insights(self, **params: Any) -> Page:
|
|
38
|
+
"""List insights (paginated). Params: page, per_page, locale, type, coin, search, sort."""
|
|
39
|
+
return self._http.get_page("/api/v1/insights", params)
|
|
40
|
+
|
|
41
|
+
def insight(self, id: Union[str, int], **params: Any) -> Any:
|
|
42
|
+
"""Get a single insight by id. Params: locale."""
|
|
43
|
+
return self._http.get(f"/api/v1/insights/{self._enc(id)}", params)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._base import Resource
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IndicatorsResource(Resource):
|
|
9
|
+
"""Technical indicators for a coin.
|
|
10
|
+
|
|
11
|
+
Period indicators (rsi, stoch_rsi, sma, cci, mfi, williams_r, price_volatility,
|
|
12
|
+
volume_volatility) accept: period, start, end, limit.
|
|
13
|
+
Series indicators (macd, obv, adx, vwap, cmf) accept: start, end, limit.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def _indicator(self, slug: str, name: str, params: dict) -> Any:
|
|
17
|
+
return self._http.get(f"/api/v1/coins/{self._enc(slug)}/indicators/{name}", params)
|
|
18
|
+
|
|
19
|
+
def snapshot(self, slug: str) -> Any:
|
|
20
|
+
"""All indicators for a coin at a glance."""
|
|
21
|
+
return self._http.get(f"/api/v1/coins/{self._enc(slug)}/indicators")
|
|
22
|
+
|
|
23
|
+
def rsi(self, slug: str, **params: Any) -> Any:
|
|
24
|
+
"""Relative Strength Index."""
|
|
25
|
+
return self._indicator(slug, "rsi", params)
|
|
26
|
+
|
|
27
|
+
def stoch_rsi(self, slug: str, **params: Any) -> Any:
|
|
28
|
+
"""Stochastic RSI."""
|
|
29
|
+
return self._indicator(slug, "stoch-rsi", params)
|
|
30
|
+
|
|
31
|
+
def sma(self, slug: str, **params: Any) -> Any:
|
|
32
|
+
"""Simple Moving Average."""
|
|
33
|
+
return self._indicator(slug, "sma", params)
|
|
34
|
+
|
|
35
|
+
def cci(self, slug: str, **params: Any) -> Any:
|
|
36
|
+
"""Commodity Channel Index."""
|
|
37
|
+
return self._indicator(slug, "cci", params)
|
|
38
|
+
|
|
39
|
+
def mfi(self, slug: str, **params: Any) -> Any:
|
|
40
|
+
"""Money Flow Index."""
|
|
41
|
+
return self._indicator(slug, "mfi", params)
|
|
42
|
+
|
|
43
|
+
def williams_r(self, slug: str, **params: Any) -> Any:
|
|
44
|
+
"""Williams %R."""
|
|
45
|
+
return self._indicator(slug, "williams-r", params)
|
|
46
|
+
|
|
47
|
+
def price_volatility(self, slug: str, **params: Any) -> Any:
|
|
48
|
+
"""Price volatility."""
|
|
49
|
+
return self._indicator(slug, "price-volatility", params)
|
|
50
|
+
|
|
51
|
+
def volume_volatility(self, slug: str, **params: Any) -> Any:
|
|
52
|
+
"""Volume volatility."""
|
|
53
|
+
return self._indicator(slug, "volume-volatility", params)
|
|
54
|
+
|
|
55
|
+
def macd(self, slug: str, **params: Any) -> Any:
|
|
56
|
+
"""Moving Average Convergence Divergence."""
|
|
57
|
+
return self._indicator(slug, "macd", params)
|
|
58
|
+
|
|
59
|
+
def obv(self, slug: str, **params: Any) -> Any:
|
|
60
|
+
"""On-Balance Volume."""
|
|
61
|
+
return self._indicator(slug, "obv", params)
|
|
62
|
+
|
|
63
|
+
def adx(self, slug: str, **params: Any) -> Any:
|
|
64
|
+
"""Average Directional Index."""
|
|
65
|
+
return self._indicator(slug, "adx", params)
|
|
66
|
+
|
|
67
|
+
def vwap(self, slug: str, **params: Any) -> Any:
|
|
68
|
+
"""Volume-Weighted Average Price."""
|
|
69
|
+
return self._indicator(slug, "vwap", params)
|
|
70
|
+
|
|
71
|
+
def cmf(self, slug: str, **params: Any) -> Any:
|
|
72
|
+
"""Chaikin Money Flow."""
|
|
73
|
+
return self._indicator(slug, "cmf", params)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CalculatorsResource(Resource):
|
|
77
|
+
"""Financial calculators."""
|
|
78
|
+
|
|
79
|
+
def dca(self, **params: Any) -> Any:
|
|
80
|
+
"""Dollar-cost-averaging calculator. Params: slug, amount, interval, start, end, series."""
|
|
81
|
+
return self._http.get("/api/v1/calculators/dca", params)
|
|
82
|
+
|
|
83
|
+
def profit_loss(self, **params: Any) -> Any:
|
|
84
|
+
"""Profit / loss calculator. Params: slug, amount, buy_date, sell_date, buy_fee, sell_fee."""
|
|
85
|
+
return self._http.get("/api/v1/calculators/profit-loss", params)
|
|
86
|
+
|
|
87
|
+
def compound_interest(self, **params: Any) -> Any:
|
|
88
|
+
"""Compound interest calculator. Params: principal, rate, duration, duration_unit,
|
|
89
|
+
compound_frequency, contribution, contribution_frequency."""
|
|
90
|
+
return self._http.get("/api/v1/calculators/compound-interest", params)
|
|
91
|
+
|
|
92
|
+
def loan(self, **params: Any) -> Any:
|
|
93
|
+
"""Loan-vs-sell calculator. Params: slug, crypto_amount, needed_cash, term_months,
|
|
94
|
+
interest_rate, ltv, expected_growth, tax_rate."""
|
|
95
|
+
return self._http.get("/api/v1/calculators/loan", params)
|
|
96
|
+
|
|
97
|
+
def staking(self, **params: Any) -> Any:
|
|
98
|
+
"""Staking rewards calculator. Params: amount, period, period_unit, apy,
|
|
99
|
+
compound_frequency, commission."""
|
|
100
|
+
return self._http.get("/api/v1/calculators/staking", params)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..pagination import Page
|
|
6
|
+
from ._base import Resource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GlobalMarketResource(Resource):
|
|
10
|
+
"""Global market aggregates."""
|
|
11
|
+
|
|
12
|
+
def snapshot(self) -> Any:
|
|
13
|
+
"""Global market snapshot (total marketcap, volume, dominance, ...)."""
|
|
14
|
+
return self._http.get("/api/v1/global")
|
|
15
|
+
|
|
16
|
+
def heatmap(self) -> Any:
|
|
17
|
+
"""Market heatmap data."""
|
|
18
|
+
return self._http.get("/api/v1/global/heatmap")
|
|
19
|
+
|
|
20
|
+
def history(self, metric: str, **params: Any) -> Any:
|
|
21
|
+
"""Global marketcap or volume history. ``metric`` is e.g. ``marketcap`` or ``volume``. Params: period."""
|
|
22
|
+
return self._http.get(f"/api/v1/global/history/{self._enc(metric)}", params)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SentimentResource(Resource):
|
|
26
|
+
"""Community sentiment and market-wide sentiment indices."""
|
|
27
|
+
|
|
28
|
+
def votes(self, slug: str) -> Any:
|
|
29
|
+
"""Community vote tallies for a coin."""
|
|
30
|
+
return self._http.get(f"/api/v1/coins/{self._enc(slug)}/votes")
|
|
31
|
+
|
|
32
|
+
def vote(self, slug: str, *, vote: str) -> Any:
|
|
33
|
+
"""Cast a sentiment vote for a coin (``bullish`` or ``bearish``)."""
|
|
34
|
+
return self._http.post(f"/api/v1/coins/{self._enc(slug)}/votes", {"vote": vote})
|
|
35
|
+
|
|
36
|
+
def fear_greed(self, **params: Any) -> Any:
|
|
37
|
+
"""Fear & Greed index. Params: coin."""
|
|
38
|
+
return self._http.get("/api/v1/sentiment/fear-greed", params)
|
|
39
|
+
|
|
40
|
+
def bull_bear(self, **params: Any) -> Any:
|
|
41
|
+
"""Bull / Bear index. Params: coin."""
|
|
42
|
+
return self._http.get("/api/v1/sentiment/bull-bear", params)
|
|
43
|
+
|
|
44
|
+
def altseason(self, **params: Any) -> Any:
|
|
45
|
+
"""Altseason index. Params: days."""
|
|
46
|
+
return self._http.get("/api/v1/sentiment/altseason", params)
|
|
47
|
+
|
|
48
|
+
def indicators(self) -> Any:
|
|
49
|
+
"""Aggregate market indicator tally."""
|
|
50
|
+
return self._http.get("/api/v1/sentiment/indicators")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LiquidationsResource(Resource):
|
|
54
|
+
"""Liquidation feed and aggregates."""
|
|
55
|
+
|
|
56
|
+
def feed(self, **params: Any) -> Page:
|
|
57
|
+
"""Live liquidation feed (paginated). Params: page, per_page, exchange,
|
|
58
|
+
instrument, position, order, symbol, min_usd."""
|
|
59
|
+
return self._http.get_page("/api/v1/liquidations", params)
|
|
60
|
+
|
|
61
|
+
def hourly(self, **params: Any) -> Any:
|
|
62
|
+
"""Hourly liquidation totals. Params: hours."""
|
|
63
|
+
return self._http.get("/api/v1/liquidations/hourly", params)
|
|
64
|
+
|
|
65
|
+
def daily(self, **params: Any) -> Any:
|
|
66
|
+
"""Daily liquidation totals. Params: days."""
|
|
67
|
+
return self._http.get("/api/v1/liquidations/daily", params)
|
|
68
|
+
|
|
69
|
+
def summary(self) -> Any:
|
|
70
|
+
"""Today's liquidation summary."""
|
|
71
|
+
return self._http.get("/api/v1/liquidations/summary")
|
|
72
|
+
|
|
73
|
+
def netflow(self, **params: Any) -> Any:
|
|
74
|
+
"""Long/short liquidation netflow. Params: days."""
|
|
75
|
+
return self._http.get("/api/v1/liquidations/netflow", params)
|
|
76
|
+
|
|
77
|
+
def coins(self, **params: Any) -> Any:
|
|
78
|
+
"""Most-liquidated coins. Params: hours, limit."""
|
|
79
|
+
return self._http.get("/api/v1/liquidations/coins", params)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Union
|
|
4
|
+
|
|
5
|
+
from ..pagination import Page
|
|
6
|
+
from ._base import Resource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MarketsResource(Resource):
|
|
10
|
+
"""Markets, tickers, and trading pairs."""
|
|
11
|
+
|
|
12
|
+
def coin_markets(self, slug: str, **params: Any) -> Page:
|
|
13
|
+
"""Markets (tickers) for a coin (paginated). Params: page, per_page, exchange,
|
|
14
|
+
pair, instrument, search, min_volume, max_volume, min_change, max_change, sort."""
|
|
15
|
+
return self._http.get_page(f"/api/v1/coins/{self._enc(slug)}/markets", params)
|
|
16
|
+
|
|
17
|
+
def coin_symbols(self, slug: str) -> Any:
|
|
18
|
+
"""Trading symbols for a coin."""
|
|
19
|
+
return self._http.get(f"/api/v1/coins/{self._enc(slug)}/symbols")
|
|
20
|
+
|
|
21
|
+
def tickers(self, **params: Any) -> Page:
|
|
22
|
+
"""List tickers across the market (paginated). Same params as :meth:`coin_markets`."""
|
|
23
|
+
return self._http.get_page("/api/v1/tickers", params)
|
|
24
|
+
|
|
25
|
+
def pairs(self, **params: Any) -> Page:
|
|
26
|
+
"""List trading pairs (paginated). Params: page, per_page, search, coin,
|
|
27
|
+
min_volume, max_volume, sort."""
|
|
28
|
+
return self._http.get_page("/api/v1/pairs", params)
|
|
29
|
+
|
|
30
|
+
def pair(self, id: Union[str, int]) -> Any:
|
|
31
|
+
"""Get a single trading pair by id."""
|
|
32
|
+
return self._http.get(f"/api/v1/pairs/{self._enc(id)}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExchangesResource(Resource):
|
|
36
|
+
"""Exchanges: listings, trust score, history, markets, and listed assets."""
|
|
37
|
+
|
|
38
|
+
def list(self, **params: Any) -> Page:
|
|
39
|
+
"""List exchanges (paginated). Params: page, per_page, type, search, min_pairs,
|
|
40
|
+
max_pairs, min_assets, max_assets, min_volume, max_volume, ids, slugs, sort."""
|
|
41
|
+
return self._http.get_page("/api/v1/exchanges", params)
|
|
42
|
+
|
|
43
|
+
def get(self, slug: str) -> Any:
|
|
44
|
+
"""Get a single exchange by slug."""
|
|
45
|
+
return self._http.get(f"/api/v1/exchanges/{self._enc(slug)}")
|
|
46
|
+
|
|
47
|
+
def trust_score(self, slug: str) -> Any:
|
|
48
|
+
"""Get an exchange's trust score."""
|
|
49
|
+
return self._http.get(f"/api/v1/exchanges/{self._enc(slug)}/trust-score")
|
|
50
|
+
|
|
51
|
+
def history(self, slug: str, **params: Any) -> Any:
|
|
52
|
+
"""Volume/history for an exchange. Params: interval, start, end, limit."""
|
|
53
|
+
return self._http.get(f"/api/v1/exchanges/{self._enc(slug)}/history", params)
|
|
54
|
+
|
|
55
|
+
def sparkline(self, slug: str, **params: Any) -> Any:
|
|
56
|
+
"""Sparkline for an exchange. Params: period."""
|
|
57
|
+
return self._http.get(f"/api/v1/exchanges/{self._enc(slug)}/sparkline", params)
|
|
58
|
+
|
|
59
|
+
def tickers(self, slug: str, **params: Any) -> Page:
|
|
60
|
+
"""Markets (tickers) on an exchange (paginated)."""
|
|
61
|
+
return self._http.get_page(f"/api/v1/exchanges/{self._enc(slug)}/tickers", params)
|
|
62
|
+
|
|
63
|
+
def assets(self, slug: str, **params: Any) -> Page:
|
|
64
|
+
"""Coins listed on an exchange (paginated)."""
|
|
65
|
+
return self._http.get_page(f"/api/v1/exchanges/{self._enc(slug)}/assets", params)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WalletsResource(Resource):
|
|
69
|
+
"""Wallets: listings, release timeline, comparison, and supported coins."""
|
|
70
|
+
|
|
71
|
+
def list(self, **params: Any) -> Page:
|
|
72
|
+
"""List wallets (paginated). Params: page, per_page, search, min_score,
|
|
73
|
+
max_score, tags, ids, slugs, sort."""
|
|
74
|
+
return self._http.get_page("/api/v1/wallets", params)
|
|
75
|
+
|
|
76
|
+
def timeline(self, **params: Any) -> Page:
|
|
77
|
+
"""Wallet release timeline (paginated). Same params as :meth:`list`."""
|
|
78
|
+
return self._http.get_page("/api/v1/wallets/timeline", params)
|
|
79
|
+
|
|
80
|
+
def compare(self, **params: Any) -> Any:
|
|
81
|
+
"""Compare a set of wallets side by side. Params: slugs."""
|
|
82
|
+
return self._http.get("/api/v1/wallets/compare", params)
|
|
83
|
+
|
|
84
|
+
def get(self, slug: str) -> Any:
|
|
85
|
+
"""Get a single wallet by slug."""
|
|
86
|
+
return self._http.get(f"/api/v1/wallets/{self._enc(slug)}")
|
|
87
|
+
|
|
88
|
+
def assets(self, slug: str, **params: Any) -> Page:
|
|
89
|
+
"""Coins supported by a wallet (paginated)."""
|
|
90
|
+
return self._http.get_page(f"/api/v1/wallets/{self._enc(slug)}/assets", params)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bitculator
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the Bitculator Data API
|
|
5
|
+
Project-URL: Documentation, https://bitculator.com/en/documentation/api/v1
|
|
6
|
+
Project-URL: Repository, https://github.com/bitculator/bitculator-python-sdk
|
|
7
|
+
Author: Bitculator
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api,bitculator,crypto,cryptocurrency,market-data,sdk
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Bitculator Data API — Python SDK
|
|
19
|
+
|
|
20
|
+
[bitculator.com](https://bitculator.com) · [API documentation](https://bitculator.com/en/documentation/api/v1) · [Get an API key](https://bitculator.com/user/developer/api)
|
|
21
|
+
[PyPI — `bitculator`](https://pypi.org/project/bitculator/) · [GitHub](https://github.com/Bitculator/bitculator-python-sdk)
|
|
22
|
+
|
|
23
|
+
The official Python SDK for the [Bitculator Data API](https://bitculator.com).
|
|
24
|
+
Zero dependencies (standard-library only), typed, works on Python 3.9+.
|
|
25
|
+
|
|
26
|
+
> Keep your API key server-side. It is a Bearer token with the `data-api` ability
|
|
27
|
+
> and is not meant for client-side embedding.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install bitculator
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
from bitculator import Bitculator
|
|
40
|
+
|
|
41
|
+
client = Bitculator(os.environ["BITCULATOR_API_KEY"])
|
|
42
|
+
|
|
43
|
+
# Single resource — the {"data": ...} envelope is unwrapped for you.
|
|
44
|
+
bitcoin = client.coins.get("bitcoin")
|
|
45
|
+
print(bitcoin["price"]) # "63520.780763913" — a decimal string, never rounded
|
|
46
|
+
|
|
47
|
+
# A paginated list.
|
|
48
|
+
page = client.coins.list(per_page=50, sort="-marketcap")
|
|
49
|
+
print(page.data, page.meta["total"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Prices are decimal strings
|
|
53
|
+
|
|
54
|
+
Prices, rates, and supplies come back as **strings** (e.g. `"63520.780763913"`)
|
|
55
|
+
to preserve full precision. Use `decimal.Decimal` if you need to do math — do
|
|
56
|
+
**not** pass them through `float()`, which silently loses precision.
|
|
57
|
+
|
|
58
|
+
## Pagination
|
|
59
|
+
|
|
60
|
+
Every paginated method returns a `Page`. Read one page, or iterate to walk them all:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# One page at a time.
|
|
64
|
+
page = client.exchanges.list(per_page=100)
|
|
65
|
+
while page is not None:
|
|
66
|
+
for exchange in page.data:
|
|
67
|
+
print(exchange["name"])
|
|
68
|
+
page = page.next_page()
|
|
69
|
+
|
|
70
|
+
# Or auto-paginate across every page (iterating a Page walks all pages).
|
|
71
|
+
for coin in client.coins.list(sort="-marketcap"):
|
|
72
|
+
print(coin["symbol"])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Errors
|
|
76
|
+
|
|
77
|
+
Every failure derives from `BitculatorError`; HTTP failures raise a status-specific
|
|
78
|
+
subclass carrying the `{"error": {"code", "message", "details"}}` envelope.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from bitculator import ValidationError, RateLimitError, APIError
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
client.coins.list(per_page=9999) # over the plan cap -> 422
|
|
85
|
+
except ValidationError as err:
|
|
86
|
+
print(err.code, err.details)
|
|
87
|
+
except RateLimitError as err:
|
|
88
|
+
print("retry after", err.retry_after)
|
|
89
|
+
except APIError as err:
|
|
90
|
+
print(err.status, err.message)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Status | Exception |
|
|
94
|
+
| ------ | --------- |
|
|
95
|
+
| 401 | `AuthenticationError` |
|
|
96
|
+
| 403 | `PermissionDeniedError` (plan does not include this endpoint) |
|
|
97
|
+
| 404 | `NotFoundError` |
|
|
98
|
+
| 422 | `ValidationError` (`.details` holds the field errors) |
|
|
99
|
+
| 429 | `RateLimitError` (`.retry_after`) |
|
|
100
|
+
| 5xx | `ServerError` |
|
|
101
|
+
|
|
102
|
+
Timeouts raise `APITimeoutError`; network failures raise `APIConnectionError`.
|
|
103
|
+
|
|
104
|
+
## Quota
|
|
105
|
+
|
|
106
|
+
Every response carries `X-Quota-*` headers, surfaced on the client after each call:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
client.coins.list()
|
|
110
|
+
print(client.quota) # Quota(limit=..., remaining=..., reset=..., raw={...})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Configuration
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
Bitculator(
|
|
117
|
+
api_key, # required (positional)
|
|
118
|
+
base_url="https://bitculator.com", # default
|
|
119
|
+
timeout=30.0, # seconds, default
|
|
120
|
+
max_retries=2, # auto-retry 429 / 5xx / network; 0 to disable
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Passing parameters
|
|
125
|
+
|
|
126
|
+
Query parameters are keyword arguments and mirror the API's names exactly
|
|
127
|
+
(`per_page`, `min_price`, ...). For the reserved word `from` (in `convert`), pass
|
|
128
|
+
`from_` — the SDK maps it back to `from` on the wire:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
client.conversion.convert(from_="btc", to="usd", amount=1)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Resources
|
|
135
|
+
|
|
136
|
+
`coins` · `prices` · `markets` · `exchanges` · `wallets` · `global_market` ·
|
|
137
|
+
`sentiment` · `indicators` · `liquidations` · `conversion` · `calculators` ·
|
|
138
|
+
`editorial` · `alarms` · `webhooks` · `meta`
|
|
139
|
+
|
|
140
|
+
Full endpoint reference: <https://bitculator.com/en/documentation/api/v1>.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
bitculator/__init__.py,sha256=s5kZCaC2slta55r6fZFoA1XtrW_Ur0zCU7FQghIOOtY,756
|
|
2
|
+
bitculator/_http.py,sha256=su9sQwsoCsOe5Z5knJGjtFYLT2P7Jy6LZ4jzRx1NK4A,8019
|
|
3
|
+
bitculator/_version.py,sha256=Z3eiQCE7mmbeS0xWQqezhPLXUAoEaeLJBVI1TPuNVRU,150
|
|
4
|
+
bitculator/client.py,sha256=2cI3Cw4fdaonrSQK2upU387H_VLa1WYsxY7QSJXsEPA,2760
|
|
5
|
+
bitculator/errors.py,sha256=MGD7VODalgG1EWH3jSffCvdZYHlUZF5u0kCNOOkINlw,3570
|
|
6
|
+
bitculator/pagination.py,sha256=B-x-WefqgmQJE2E_ZkxeDk_C1LDZBskUVKDl94_nAr0,2200
|
|
7
|
+
bitculator/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
bitculator/resources/__init__.py,sha256=rcJwyzPxSTo-hr7UdiAanv6bXgYPskzi7oEn09lt9xA,777
|
|
9
|
+
bitculator/resources/_base.py,sha256=YzA5cqiWfWgmCQc0SPmTdNrygEnFb_BHuurdRfGW9zw,492
|
|
10
|
+
bitculator/resources/account.py,sha256=V5UbbgewXb1icDXuq1U5s9U5GR4fc7gFGs6hfQFdSNY,2570
|
|
11
|
+
bitculator/resources/coins.py,sha256=559KBjhgUO6BCQ1TJLsRIkW-0TZVo4PHpT43jvrUhMw,3224
|
|
12
|
+
bitculator/resources/editorial.py,sha256=TZ3_3CSJcJTrcxoxj5Z84Gb0TU23-agHKA_JuF3y1A0,1902
|
|
13
|
+
bitculator/resources/indicators.py,sha256=01EyAAKguU6S0or96rgY-8Isl48SpRSOyUoGIjx4OIw,3949
|
|
14
|
+
bitculator/resources/market.py,sha256=e4PBKDbrO4g5DBh4d62CUCzA3XrP_KeC1x1U5QRc2EU,3003
|
|
15
|
+
bitculator/resources/markets.py,sha256=xhJgkaRkLPGswA3Tl6JT07rLY1F5JS3c0aRmjIDD_wY,3932
|
|
16
|
+
bitculator-1.0.0.dist-info/METADATA,sha256=3I42OhDqRufhqci2-x8pK48dHPXqe1DIa8S55Ug0IoQ,4481
|
|
17
|
+
bitculator-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
18
|
+
bitculator-1.0.0.dist-info/licenses/LICENSE,sha256=IiTpcYvOjmCuzZyrQrcY410bxaA52Cho8PYj6S1sjJA,1067
|
|
19
|
+
bitculator-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bitculator
|
|
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.
|