clarifindata 0.2.5__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.
- clarifindata/__init__.py +30 -0
- clarifindata/client.py +489 -0
- clarifindata/datasets.py +148 -0
- clarifindata/errors.py +48 -0
- clarifindata-0.2.5.dist-info/METADATA +105 -0
- clarifindata-0.2.5.dist-info/RECORD +8 -0
- clarifindata-0.2.5.dist-info/WHEEL +4 -0
- clarifindata-0.2.5.dist-info/licenses/LICENSE +21 -0
clarifindata/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""clarifindata — Python client for the 金晰數據 Taiwan-stock data API.
|
|
2
|
+
|
|
3
|
+
from clarifindata import Client, datasets
|
|
4
|
+
cfd = Client(api_key="cfd_demo_free_0001")
|
|
5
|
+
df = cfd.get(datasets.TaiwanStockPrice, stock_id="2330",
|
|
6
|
+
start="2026-04-01", as_df=True)
|
|
7
|
+
|
|
8
|
+
Concurrency (quant default):
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from clarifindata import AsyncClient, datasets
|
|
12
|
+
async def main():
|
|
13
|
+
async with AsyncClient(api_key="...") as cfd:
|
|
14
|
+
out = await asyncio.gather(*[
|
|
15
|
+
cfd.get(datasets.TaiwanStockPrice, stock_id=s) for s in ("2330", "2317")])
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from . import datasets
|
|
20
|
+
from .client import AsyncClient, Client, __version__
|
|
21
|
+
from .datasets import DATASET_ROW_TYPES, DatasetRow, Row
|
|
22
|
+
from .errors import (AuthError, ClarifindataError, NotFoundError, RateLimitError,
|
|
23
|
+
TierError, ValidationError)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Client", "AsyncClient", "datasets", "__version__",
|
|
27
|
+
"DatasetRow", "Row", "DATASET_ROW_TYPES",
|
|
28
|
+
"ClarifindataError", "AuthError", "TierError", "RateLimitError",
|
|
29
|
+
"NotFoundError", "ValidationError",
|
|
30
|
+
]
|
clarifindata/client.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""clarifindata Python client — sync (`Client`) and async (`AsyncClient`).
|
|
2
|
+
|
|
3
|
+
from clarifindata import Client, datasets
|
|
4
|
+
cfd = Client(api_key="cfd_demo_free_0001")
|
|
5
|
+
df = cfd.get(datasets.TaiwanStockPrice, stock_id="2330", start="2026-04-01", as_df=True)
|
|
6
|
+
|
|
7
|
+
Features: automatic retry (429/5xx, honours Retry-After), rate-limit awareness,
|
|
8
|
+
DataFrame output, date-chunked full-history streaming, optional disk cache,
|
|
9
|
+
typed errors, and an AsyncClient with the same surface.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from datetime import date, datetime
|
|
19
|
+
from typing import Any, Iterator
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from . import datasets as _ds
|
|
24
|
+
from .errors import (AuthError, ClarifindataError, NotFoundError, RateLimitError,
|
|
25
|
+
TierError, ValidationError)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from importlib.metadata import version as _pkg_version
|
|
29
|
+
__version__ = _pkg_version("clarifindata")
|
|
30
|
+
except Exception: # not installed (running from source)
|
|
31
|
+
__version__ = "0.0.0+src"
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("clarifindata")
|
|
34
|
+
DEFAULT_BASE_URL = "https://api.clarifindata.com"
|
|
35
|
+
ROW_CAP = 10000 # server hard cap per request
|
|
36
|
+
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ───────────────────────── shared helpers ─────────────────────────
|
|
40
|
+
def _mask(key: str) -> str:
|
|
41
|
+
return f"{key[:8]}…{key[-2:]}" if len(key) > 10 else "***"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _backoff(attempt: int, base: float, retry_after: float | None) -> float:
|
|
45
|
+
if retry_after is not None:
|
|
46
|
+
return retry_after
|
|
47
|
+
# exponential + light deterministic jitter (no global RNG dependency)
|
|
48
|
+
return min(60.0, base * (2 ** attempt)) * (1.0 + 0.1 * (attempt % 3))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _suggest(status: int, dataset: str | None) -> str:
|
|
52
|
+
if status in (401,):
|
|
53
|
+
return "Check your api_key (Authorization: Bearer <key>)."
|
|
54
|
+
if status == 403:
|
|
55
|
+
tier = _ds.DATASET_TIERS.get(dataset or "", "?")
|
|
56
|
+
return f"This needs a higher tier (dataset '{dataset}' requires '{tier}'). Upgrade or use a free dataset."
|
|
57
|
+
if status == 404:
|
|
58
|
+
return "Unknown dataset/endpoint — see clarifindata.datasets.ALL or cfd.catalog()."
|
|
59
|
+
if status in (400, 422):
|
|
60
|
+
return "Bad params — check the dataset name (clarifindata.datasets.*), date format (YYYY-MM-DD), and limit ≤ 10000."
|
|
61
|
+
if status == 429:
|
|
62
|
+
return "Rate limit — the client retries automatically; lower concurrency or upgrade tier."
|
|
63
|
+
return "See https://clarifindata.com/docs"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _err_from_response(r: httpx.Response, dataset: str | None) -> ClarifindataError:
|
|
67
|
+
try:
|
|
68
|
+
detail = r.json().get("detail")
|
|
69
|
+
except Exception:
|
|
70
|
+
detail = (r.text or "")[:200]
|
|
71
|
+
kw = dict(dataset=dataset, status=r.status_code, detail=detail,
|
|
72
|
+
suggestion=_suggest(r.status_code, dataset))
|
|
73
|
+
s = r.status_code
|
|
74
|
+
if s == 403:
|
|
75
|
+
return TierError("Forbidden", **kw)
|
|
76
|
+
if s == 401:
|
|
77
|
+
return AuthError("Unauthorized", **kw)
|
|
78
|
+
if s == 404:
|
|
79
|
+
return NotFoundError("Not found", **kw)
|
|
80
|
+
if s == 429:
|
|
81
|
+
ra = r.headers.get("retry-after")
|
|
82
|
+
return RateLimitError("Rate limited", retry_after=float(ra) if ra else None, **kw)
|
|
83
|
+
if s in (400, 422):
|
|
84
|
+
return ValidationError("Invalid request", **kw)
|
|
85
|
+
return ClarifindataError("HTTP error", **kw)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _validate_dataset(dataset: str) -> None:
|
|
89
|
+
if dataset not in _ds.ALL:
|
|
90
|
+
# offer a close match for typos
|
|
91
|
+
import difflib
|
|
92
|
+
near = difflib.get_close_matches(dataset, _ds.ALL, n=1)
|
|
93
|
+
hint = f" Did you mean '{near[0]}'?" if near else ""
|
|
94
|
+
raise ValidationError(f"Unknown dataset '{dataset}'.{hint}",
|
|
95
|
+
dataset=dataset,
|
|
96
|
+
suggestion="Use clarifindata.datasets.* for valid names.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _params(stock_id, start, end, limit, cursor=None) -> dict[str, Any]:
|
|
100
|
+
p: dict[str, Any] = {"limit": limit}
|
|
101
|
+
if stock_id is not None:
|
|
102
|
+
p["stock_id"] = stock_id
|
|
103
|
+
if start is not None:
|
|
104
|
+
p["start"] = str(start)
|
|
105
|
+
if end is not None:
|
|
106
|
+
p["end"] = str(end)
|
|
107
|
+
if cursor is not None:
|
|
108
|
+
p["cursor"] = cursor
|
|
109
|
+
return p
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _to_df(rows: list[dict[str, Any]]):
|
|
113
|
+
try:
|
|
114
|
+
import pandas as pd
|
|
115
|
+
except ImportError as e:
|
|
116
|
+
raise ClarifindataError(
|
|
117
|
+
"pandas not installed", suggestion="pip install 'clarifindata[pandas]'") from e
|
|
118
|
+
return pd.DataFrame(rows)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _cache_path(cache_dir: str, method: str, url: str, params: dict) -> str:
|
|
122
|
+
key = hashlib.sha256(f"{method}{url}{sorted(params.items())}".encode()).hexdigest()
|
|
123
|
+
return os.path.join(cache_dir, key + ".json")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _month_chunks(since: date, until: date):
|
|
127
|
+
"""Yield (chunk_start, chunk_end) spanning since..until, one calendar year
|
|
128
|
+
each — small enough to stay under the 10000-row cap for one stock."""
|
|
129
|
+
cur = since
|
|
130
|
+
while cur <= until:
|
|
131
|
+
end = min(date(cur.year, 12, 31), until)
|
|
132
|
+
yield cur, end
|
|
133
|
+
cur = date(cur.year + 1, 1, 1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _as_date(d) -> date:
|
|
137
|
+
if isinstance(d, date):
|
|
138
|
+
return d
|
|
139
|
+
return datetime.strptime(str(d), "%Y-%m-%d").date()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ───────────────────────── sync client ─────────────────────────
|
|
143
|
+
class Client:
|
|
144
|
+
def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL,
|
|
145
|
+
timeout: float = 30.0, max_retries: int = 4, backoff_base: float = 0.5,
|
|
146
|
+
cache_dir: str | None = None, cache_ttl: float = 86400.0,
|
|
147
|
+
warn_rate_limit_pct: float = 80.0) -> None:
|
|
148
|
+
if not api_key:
|
|
149
|
+
raise ValueError("api_key is required (get a free key at clarifindata.com/signup)")
|
|
150
|
+
self.api_key = api_key
|
|
151
|
+
self.base_url = base_url.rstrip("/")
|
|
152
|
+
self.max_retries = max_retries
|
|
153
|
+
self.backoff_base = backoff_base
|
|
154
|
+
self.cache_dir = cache_dir
|
|
155
|
+
self.cache_ttl = cache_ttl
|
|
156
|
+
self.warn_rate_limit_pct = warn_rate_limit_pct
|
|
157
|
+
self.rate_limit_limit: int | None = None
|
|
158
|
+
self.rate_limit_remaining: int | None = None
|
|
159
|
+
self.tier: str | None = None
|
|
160
|
+
if cache_dir:
|
|
161
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
162
|
+
try:
|
|
163
|
+
self._http = httpx.Client(base_url=self.base_url, timeout=timeout, http2=True,
|
|
164
|
+
headers=self._headers())
|
|
165
|
+
except Exception: # h2 not installed → HTTP/1.1
|
|
166
|
+
self._http = httpx.Client(base_url=self.base_url, timeout=timeout,
|
|
167
|
+
headers=self._headers())
|
|
168
|
+
|
|
169
|
+
def _headers(self) -> dict[str, str]:
|
|
170
|
+
return {"Authorization": f"Bearer {self.api_key}",
|
|
171
|
+
"User-Agent": f"clarifindata-py/{__version__}"}
|
|
172
|
+
|
|
173
|
+
# lifecycle
|
|
174
|
+
def __enter__(self) -> "Client":
|
|
175
|
+
return self
|
|
176
|
+
|
|
177
|
+
def __exit__(self, *_: Any) -> None:
|
|
178
|
+
self.close()
|
|
179
|
+
|
|
180
|
+
def __del__(self) -> None:
|
|
181
|
+
try:
|
|
182
|
+
self.close()
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def close(self) -> None:
|
|
187
|
+
if getattr(self, "_http", None) is not None:
|
|
188
|
+
self._http.close()
|
|
189
|
+
|
|
190
|
+
def __repr__(self) -> str:
|
|
191
|
+
rem = self.rate_limit_remaining
|
|
192
|
+
return (f"Client(base_url={self.base_url!r}, key={_mask(self.api_key)}, "
|
|
193
|
+
f"tier={self.tier}, rate_limit_remaining={rem})")
|
|
194
|
+
|
|
195
|
+
# core request with retry + rate-limit tracking + cache
|
|
196
|
+
def _request(self, method: str, path: str, *, params: dict | None = None,
|
|
197
|
+
json_body: dict | None = None, dataset: str | None = None) -> dict:
|
|
198
|
+
params = params or {}
|
|
199
|
+
cache_file = None
|
|
200
|
+
if self.cache_dir and method == "GET":
|
|
201
|
+
cache_file = _cache_path(self.cache_dir, method, path, params)
|
|
202
|
+
if os.path.exists(cache_file) and (time.time() - os.path.getmtime(cache_file)) < self.cache_ttl:
|
|
203
|
+
log.debug("cache hit %s", path)
|
|
204
|
+
with open(cache_file) as f:
|
|
205
|
+
return json.load(f)
|
|
206
|
+
last: Exception | None = None
|
|
207
|
+
for attempt in range(self.max_retries + 1):
|
|
208
|
+
try:
|
|
209
|
+
log.debug("%s %s params=%s (attempt %d)", method, path, params, attempt)
|
|
210
|
+
r = self._http.request(method, path, params=params, json=json_body)
|
|
211
|
+
self._track_rate_limit(r)
|
|
212
|
+
if r.status_code in _RETRY_STATUS and attempt < self.max_retries:
|
|
213
|
+
ra = r.headers.get("retry-after")
|
|
214
|
+
wait = _backoff(attempt, self.backoff_base, float(ra) if ra else None)
|
|
215
|
+
log.warning("HTTP %d on %s — retrying in %.1fs", r.status_code, path, wait)
|
|
216
|
+
time.sleep(wait)
|
|
217
|
+
continue
|
|
218
|
+
if r.status_code >= 400:
|
|
219
|
+
raise _err_from_response(r, dataset)
|
|
220
|
+
body = r.json()
|
|
221
|
+
if cache_file is not None:
|
|
222
|
+
with open(cache_file, "w") as f:
|
|
223
|
+
json.dump(body, f)
|
|
224
|
+
return body
|
|
225
|
+
except (httpx.TimeoutException, httpx.TransportError) as e:
|
|
226
|
+
last = e
|
|
227
|
+
if attempt < self.max_retries:
|
|
228
|
+
wait = _backoff(attempt, self.backoff_base, None)
|
|
229
|
+
log.warning("network error on %s (%s) — retrying in %.1fs", path, e, wait)
|
|
230
|
+
time.sleep(wait)
|
|
231
|
+
continue
|
|
232
|
+
raise ClarifindataError(f"network error after {self.max_retries} retries",
|
|
233
|
+
dataset=dataset, suggestion="Check connectivity / base_url.") from e
|
|
234
|
+
raise ClarifindataError("request failed", dataset=dataset) from last
|
|
235
|
+
|
|
236
|
+
def _track_rate_limit(self, r: httpx.Response) -> None:
|
|
237
|
+
h = r.headers
|
|
238
|
+
if "x-ratelimit-limit" in h:
|
|
239
|
+
try:
|
|
240
|
+
self.rate_limit_limit = int(h["x-ratelimit-limit"])
|
|
241
|
+
self.rate_limit_remaining = int(h["x-ratelimit-remaining"])
|
|
242
|
+
self.tier = h.get("x-ratelimit-tier", self.tier)
|
|
243
|
+
used_pct = 100 * (1 - self.rate_limit_remaining / max(1, self.rate_limit_limit))
|
|
244
|
+
if used_pct >= self.warn_rate_limit_pct:
|
|
245
|
+
log.warning("rate limit %.0f%% used (%d/%d remaining this window)",
|
|
246
|
+
used_pct, self.rate_limit_remaining, self.rate_limit_limit)
|
|
247
|
+
except (ValueError, TypeError):
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
# public API
|
|
251
|
+
def catalog(self) -> list[dict[str, Any]]:
|
|
252
|
+
"""Full dataset catalog (coverage, tier, category)."""
|
|
253
|
+
return self._request("GET", "/v1/data").get("datasets", [])
|
|
254
|
+
|
|
255
|
+
def get(self, dataset: str, *, stock_id: str | None = None,
|
|
256
|
+
start: date | str | None = None, end: date | str | None = None,
|
|
257
|
+
limit: int = 1000, as_df: bool = False):
|
|
258
|
+
"""Fetch rows from `dataset`. Returns list[dict], or a DataFrame if
|
|
259
|
+
as_df=True. Warns if the result is truncated at `limit` (raise it or use
|
|
260
|
+
iter_history for full ranges)."""
|
|
261
|
+
_validate_dataset(dataset)
|
|
262
|
+
if limit > ROW_CAP:
|
|
263
|
+
raise ValidationError(f"limit {limit} exceeds server cap {ROW_CAP}",
|
|
264
|
+
dataset=dataset, suggestion="Use iter_history() to stream large ranges.")
|
|
265
|
+
body = self._request("GET", f"/v1/data/{dataset}",
|
|
266
|
+
params=_params(stock_id, start, end, limit), dataset=dataset)
|
|
267
|
+
rows = body.get("data", [])
|
|
268
|
+
if len(rows) == limit and body.get("next_cursor") is None:
|
|
269
|
+
log.warning("'%s' returned exactly limit=%d rows — result is likely TRUNCATED. "
|
|
270
|
+
"Raise limit or use iter_history().", dataset, limit)
|
|
271
|
+
return _to_df(rows) if as_df else rows
|
|
272
|
+
|
|
273
|
+
def _pull_window(self, dataset: str, *, stock_id=None, start=None, end=None) -> list:
|
|
274
|
+
"""Fully drain one (stock_id, start, end) window by following the server's
|
|
275
|
+
keyset `next_cursor` until it is null. Returns all rows as one list[dict].
|
|
276
|
+
This is what lets unbounded datasets (e.g. WarrantInfo, ~126k rows) be
|
|
277
|
+
collected despite the per-request ROW_CAP."""
|
|
278
|
+
out: list[dict[str, Any]] = []
|
|
279
|
+
cursor: str | None = None
|
|
280
|
+
while True:
|
|
281
|
+
body = self._request("GET", f"/v1/data/{dataset}",
|
|
282
|
+
params=_params(stock_id, start, end, ROW_CAP, cursor),
|
|
283
|
+
dataset=dataset)
|
|
284
|
+
out.extend(body.get("data", []))
|
|
285
|
+
cursor = body.get("next_cursor")
|
|
286
|
+
if not cursor:
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
def iter_history(self, dataset: str, *, stock_id: str | None = None,
|
|
290
|
+
since: date | str | None = None, until: date | str | None = None,
|
|
291
|
+
as_df: bool = False) -> Iterator:
|
|
292
|
+
"""Stream a full date range in year-sized chunks (auto-paginates around
|
|
293
|
+
the 10000-row cap). Yields one batch (list[dict] or DataFrame) per chunk.
|
|
294
|
+
|
|
295
|
+
When `since` is None (e.g. reference/realtime datasets with no date axis),
|
|
296
|
+
skips date chunking and yields a single batch capped at ROW_CAP."""
|
|
297
|
+
_validate_dataset(dataset)
|
|
298
|
+
if since is None:
|
|
299
|
+
# No date axis: drain the whole dataset via keyset cursor (handles
|
|
300
|
+
# datasets larger than ROW_CAP, e.g. WarrantInfo).
|
|
301
|
+
rows = self._pull_window(dataset, stock_id=stock_id)
|
|
302
|
+
if rows:
|
|
303
|
+
yield _to_df(rows) if as_df else rows
|
|
304
|
+
return
|
|
305
|
+
s = _as_date(since)
|
|
306
|
+
u = _as_date(until) if until else date.today()
|
|
307
|
+
for cs, ce in _month_chunks(s, u):
|
|
308
|
+
rows = self._pull_window(dataset, stock_id=stock_id, start=cs, end=ce)
|
|
309
|
+
if rows:
|
|
310
|
+
yield _to_df(rows) if as_df else rows
|
|
311
|
+
|
|
312
|
+
def history(self, dataset: str, **kw) -> list[dict[str, Any]]:
|
|
313
|
+
"""Convenience: fully materialise iter_history into one list (or DataFrame).
|
|
314
|
+
|
|
315
|
+
`since` is optional: omit it for datasets with no date axis (reference/
|
|
316
|
+
realtime) and it behaves like get(dataset, limit=ROW_CAP)."""
|
|
317
|
+
as_df = kw.pop("as_df", False)
|
|
318
|
+
out: list[dict[str, Any]] = []
|
|
319
|
+
for batch in self.iter_history(dataset, **kw):
|
|
320
|
+
out.extend(batch)
|
|
321
|
+
return _to_df(out) if as_df else out
|
|
322
|
+
|
|
323
|
+
def bulk_pull(self, datasets: list[str], *, start: date | str | None = None,
|
|
324
|
+
end: date | str | None = None, stock_id: str | None = None,
|
|
325
|
+
as_df: bool = False) -> dict[str, Any]:
|
|
326
|
+
"""Fetch several datasets for the same window; rate-limit-aware (pauses
|
|
327
|
+
if the window is nearly exhausted). Returns {dataset: rows|DataFrame}."""
|
|
328
|
+
out: dict[str, Any] = {}
|
|
329
|
+
for d in datasets:
|
|
330
|
+
if self.rate_limit_remaining is not None and self.rate_limit_remaining < 5:
|
|
331
|
+
log.warning("rate limit nearly exhausted — pausing 5s")
|
|
332
|
+
time.sleep(5)
|
|
333
|
+
rows = self._pull_window(d, stock_id=stock_id, start=start, end=end)
|
|
334
|
+
out[d] = _to_df(rows) if as_df else rows
|
|
335
|
+
return out
|
|
336
|
+
|
|
337
|
+
def ask(self, question: str, *, as_df: bool = False):
|
|
338
|
+
"""Natural-language → SQL query (Plus tier only)."""
|
|
339
|
+
try:
|
|
340
|
+
body = self._request("POST", "/v1/ask", json_body={"question": question})
|
|
341
|
+
except TierError as e:
|
|
342
|
+
raise TierError("ask() requires the Plus tier", status=403,
|
|
343
|
+
suggestion="Upgrade to Plus, or use get() with explicit params.") from e
|
|
344
|
+
return _to_df(body.get("data", [])) if as_df else body
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ───────────────────────── async client ─────────────────────────
|
|
348
|
+
class AsyncClient:
|
|
349
|
+
"""Async mirror of Client — same surface, for concurrent fetches.
|
|
350
|
+
|
|
351
|
+
async with AsyncClient(api_key=...) as cfd:
|
|
352
|
+
rows = await cfd.get(datasets.TaiwanStockPrice, stock_id="2330")
|
|
353
|
+
results = await asyncio.gather(*[cfd.get(..., stock_id=s) for s in stocks])
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL,
|
|
357
|
+
timeout: float = 30.0, max_retries: int = 4, backoff_base: float = 0.5,
|
|
358
|
+
warn_rate_limit_pct: float = 80.0) -> None:
|
|
359
|
+
if not api_key:
|
|
360
|
+
raise ValueError("api_key is required")
|
|
361
|
+
self.api_key = api_key
|
|
362
|
+
self.base_url = base_url.rstrip("/")
|
|
363
|
+
self.max_retries = max_retries
|
|
364
|
+
self.backoff_base = backoff_base
|
|
365
|
+
self.warn_rate_limit_pct = warn_rate_limit_pct
|
|
366
|
+
self.rate_limit_limit: int | None = None
|
|
367
|
+
self.rate_limit_remaining: int | None = None
|
|
368
|
+
self.tier: str | None = None
|
|
369
|
+
hdrs = {"Authorization": f"Bearer {api_key}", "User-Agent": f"clarifindata-py/{__version__}"}
|
|
370
|
+
try:
|
|
371
|
+
self._http = httpx.AsyncClient(base_url=self.base_url, timeout=timeout, http2=True, headers=hdrs)
|
|
372
|
+
except Exception:
|
|
373
|
+
self._http = httpx.AsyncClient(base_url=self.base_url, timeout=timeout, headers=hdrs)
|
|
374
|
+
|
|
375
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
376
|
+
return self
|
|
377
|
+
|
|
378
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
379
|
+
await self.close()
|
|
380
|
+
|
|
381
|
+
async def close(self) -> None:
|
|
382
|
+
await self._http.aclose()
|
|
383
|
+
|
|
384
|
+
def __repr__(self) -> str:
|
|
385
|
+
return f"AsyncClient(base_url={self.base_url!r}, key={_mask(self.api_key)}, tier={self.tier})"
|
|
386
|
+
|
|
387
|
+
def _track(self, r: httpx.Response) -> None:
|
|
388
|
+
Client._track_rate_limit(self, r) # reuse the same logic
|
|
389
|
+
|
|
390
|
+
async def _request(self, method: str, path: str, *, params=None, json_body=None, dataset=None) -> dict:
|
|
391
|
+
import asyncio
|
|
392
|
+
params = params or {}
|
|
393
|
+
last: Exception | None = None
|
|
394
|
+
for attempt in range(self.max_retries + 1):
|
|
395
|
+
try:
|
|
396
|
+
r = await self._http.request(method, path, params=params, json=json_body)
|
|
397
|
+
self._track(r)
|
|
398
|
+
if r.status_code in _RETRY_STATUS and attempt < self.max_retries:
|
|
399
|
+
ra = r.headers.get("retry-after")
|
|
400
|
+
await asyncio.sleep(_backoff(attempt, self.backoff_base, float(ra) if ra else None))
|
|
401
|
+
continue
|
|
402
|
+
if r.status_code >= 400:
|
|
403
|
+
raise _err_from_response(r, dataset)
|
|
404
|
+
return r.json()
|
|
405
|
+
except (httpx.TimeoutException, httpx.TransportError) as e:
|
|
406
|
+
last = e
|
|
407
|
+
if attempt < self.max_retries:
|
|
408
|
+
await asyncio.sleep(_backoff(attempt, self.backoff_base, None))
|
|
409
|
+
continue
|
|
410
|
+
raise ClarifindataError("network error after retries", dataset=dataset) from e
|
|
411
|
+
raise ClarifindataError("request failed", dataset=dataset) from last
|
|
412
|
+
|
|
413
|
+
async def catalog(self) -> list[dict[str, Any]]:
|
|
414
|
+
return (await self._request("GET", "/v1/data")).get("datasets", [])
|
|
415
|
+
|
|
416
|
+
async def get(self, dataset: str, *, stock_id=None, start=None, end=None,
|
|
417
|
+
limit: int = 1000, as_df: bool = False):
|
|
418
|
+
_validate_dataset(dataset)
|
|
419
|
+
if limit > ROW_CAP:
|
|
420
|
+
raise ValidationError(f"limit {limit} exceeds {ROW_CAP}", dataset=dataset)
|
|
421
|
+
body = await self._request("GET", f"/v1/data/{dataset}",
|
|
422
|
+
params=_params(stock_id, start, end, limit), dataset=dataset)
|
|
423
|
+
rows = body.get("data", [])
|
|
424
|
+
if len(rows) == limit and body.get("next_cursor") is None:
|
|
425
|
+
log.warning("'%s' returned exactly limit=%d — likely truncated.", dataset, limit)
|
|
426
|
+
return _to_df(rows) if as_df else rows
|
|
427
|
+
|
|
428
|
+
async def _pull_window(self, dataset: str, *, stock_id=None, start=None, end=None) -> list:
|
|
429
|
+
"""Async mirror of Client._pull_window — drains one window by following
|
|
430
|
+
`next_cursor` until null."""
|
|
431
|
+
out: list[dict[str, Any]] = []
|
|
432
|
+
cursor: str | None = None
|
|
433
|
+
while True:
|
|
434
|
+
body = await self._request("GET", f"/v1/data/{dataset}",
|
|
435
|
+
params=_params(stock_id, start, end, ROW_CAP, cursor),
|
|
436
|
+
dataset=dataset)
|
|
437
|
+
out.extend(body.get("data", []))
|
|
438
|
+
cursor = body.get("next_cursor")
|
|
439
|
+
if not cursor:
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
async def iter_history(self, dataset: str, *, stock_id=None,
|
|
443
|
+
since: date | str | None = None, until: date | str | None = None,
|
|
444
|
+
as_df: bool = False) -> list:
|
|
445
|
+
"""Async mirror of Client.iter_history. Returns the list of per-chunk
|
|
446
|
+
batches (each a list[dict] or DataFrame); iterate with a plain `for`.
|
|
447
|
+
|
|
448
|
+
When `since` is None, returns a single batch capped at ROW_CAP."""
|
|
449
|
+
_validate_dataset(dataset)
|
|
450
|
+
batches: list = []
|
|
451
|
+
if since is None:
|
|
452
|
+
rows = await self._pull_window(dataset, stock_id=stock_id)
|
|
453
|
+
if rows:
|
|
454
|
+
batches.append(_to_df(rows) if as_df else rows)
|
|
455
|
+
return batches
|
|
456
|
+
s = _as_date(since)
|
|
457
|
+
u = _as_date(until) if until else date.today()
|
|
458
|
+
for cs, ce in _month_chunks(s, u):
|
|
459
|
+
rows = await self._pull_window(dataset, stock_id=stock_id, start=cs, end=ce)
|
|
460
|
+
if rows:
|
|
461
|
+
batches.append(_to_df(rows) if as_df else rows)
|
|
462
|
+
return batches
|
|
463
|
+
|
|
464
|
+
async def history(self, dataset: str, **kw) -> list[dict[str, Any]]:
|
|
465
|
+
"""Convenience: fully materialise iter_history into one list (or DataFrame).
|
|
466
|
+
|
|
467
|
+
`since` is optional: omit it for datasets with no date axis."""
|
|
468
|
+
as_df = kw.pop("as_df", False)
|
|
469
|
+
out: list[dict[str, Any]] = []
|
|
470
|
+
for batch in await self.iter_history(dataset, **kw):
|
|
471
|
+
out.extend(batch)
|
|
472
|
+
return _to_df(out) if as_df else out
|
|
473
|
+
|
|
474
|
+
async def bulk_pull(self, datasets: list[str], *, start: date | str | None = None,
|
|
475
|
+
end: date | str | None = None, stock_id: str | None = None,
|
|
476
|
+
as_df: bool = False) -> dict[str, Any]:
|
|
477
|
+
"""Fetch several datasets for the same window concurrently.
|
|
478
|
+
Returns {dataset: rows|DataFrame}."""
|
|
479
|
+
import asyncio
|
|
480
|
+
rows_per = await asyncio.gather(*[
|
|
481
|
+
self._pull_window(d, stock_id=stock_id, start=start, end=end)
|
|
482
|
+
for d in datasets
|
|
483
|
+
])
|
|
484
|
+
return {d: (_to_df(rows) if as_df else rows)
|
|
485
|
+
for d, rows in zip(datasets, rows_per)}
|
|
486
|
+
|
|
487
|
+
async def ask(self, question: str, *, as_df: bool = False):
|
|
488
|
+
body = await self._request("POST", "/v1/ask", json_body={"question": question})
|
|
489
|
+
return _to_df(body.get("data", [])) if as_df else body
|
clarifindata/datasets.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Dataset name constants + tier map — autogenerated from the live catalog.
|
|
2
|
+
|
|
3
|
+
Use these instead of raw strings to get IDE autocomplete + catch typos early:
|
|
4
|
+
|
|
5
|
+
from clarifindata import datasets
|
|
6
|
+
cfd.get(datasets.TaiwanStockPrice, stock_id="2330")
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Type
|
|
11
|
+
|
|
12
|
+
try: # py3.8+ stdlib; kept here for an explicit, py3.9-safe import
|
|
13
|
+
from typing import TypedDict
|
|
14
|
+
except ImportError: # pragma: no cover
|
|
15
|
+
from typing_extensions import TypedDict # type: ignore[assignment]
|
|
16
|
+
|
|
17
|
+
# --- response row typing -------------------------------------------------
|
|
18
|
+
#
|
|
19
|
+
# A single API response row is a plain JSON object (dict). The concrete
|
|
20
|
+
# columns vary per dataset and are defined by the live server catalog, which
|
|
21
|
+
# is NOT shipped with this client, so we cannot hand-author a verified
|
|
22
|
+
# TypedDict per dataset without guessing column names.
|
|
23
|
+
#
|
|
24
|
+
# `DatasetRow` is the generic, always-correct row type — use it to annotate
|
|
25
|
+
# response rows and get a typed alias for `list[DatasetRow]`:
|
|
26
|
+
#
|
|
27
|
+
# from clarifindata import DatasetRow
|
|
28
|
+
# rows: list[DatasetRow] = cfd.get(datasets.TaiwanStockPrice, stock_id="2330")
|
|
29
|
+
#
|
|
30
|
+
# Per-dataset TypedDicts (for richer IDE autocomplete on columns) can be
|
|
31
|
+
# code-generated from the catalog; when available they should be registered
|
|
32
|
+
# in `DATASET_ROW_TYPES` below, mapping dataset name -> TypedDict subclass.
|
|
33
|
+
DatasetRow = Dict[str, Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Row(TypedDict, total=False):
|
|
37
|
+
"""Generic typed view of a response row.
|
|
38
|
+
|
|
39
|
+
Open shape (``total=False``) — every key is optional because columns
|
|
40
|
+
differ per dataset. Subclass this for code-generated per-dataset row
|
|
41
|
+
types and register them in :data:`DATASET_ROW_TYPES`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# dataset name -> per-dataset TypedDict row type (populate from catalog).
|
|
46
|
+
# Empty until verified per-dataset shapes are generated; consumers should
|
|
47
|
+
# fall back to ``DatasetRow`` for any dataset not present here.
|
|
48
|
+
DATASET_ROW_TYPES: Dict[str, Type[Row]] = {}
|
|
49
|
+
|
|
50
|
+
# name -> minimum tier required ("free" | "lite" | "plus")
|
|
51
|
+
DATASET_TIERS: dict[str, str] = {
|
|
52
|
+
'CentralBankRate': 'free',
|
|
53
|
+
'CrudeOilPrices': 'free',
|
|
54
|
+
'CryptoFearAndGreedIndex': 'plus',
|
|
55
|
+
'CurrencyCirculation': 'free',
|
|
56
|
+
'ExchangeRate': 'free',
|
|
57
|
+
'FearAndGreedIndex': 'plus',
|
|
58
|
+
'GoldPrice': 'free',
|
|
59
|
+
'TaiwanCBDaily': 'free',
|
|
60
|
+
'TaiwanCBDailyOverview': 'free',
|
|
61
|
+
'TaiwanCBInstitutionalInvestors': 'free',
|
|
62
|
+
'TaiwanCBOverview': 'plus',
|
|
63
|
+
'TaiwanDailyShortSaleBalances': 'free',
|
|
64
|
+
'TaiwanFinancialHoldingsCompany': 'lite',
|
|
65
|
+
'TaiwanFutOptInstitutionalInvestors': 'free',
|
|
66
|
+
'TaiwanFutureOptionDailyTotalOverview': 'free',
|
|
67
|
+
'TaiwanFutureOptionTickRealtime': 'free',
|
|
68
|
+
'TaiwanFuturesBrokerDaily': 'lite',
|
|
69
|
+
'TaiwanFuturesBrokerInfo': 'free',
|
|
70
|
+
'TaiwanFuturesDaily': 'free',
|
|
71
|
+
'TaiwanFuturesInstitutionalInvestors': 'free',
|
|
72
|
+
'TaiwanFuturesLargeTraders': 'lite',
|
|
73
|
+
'TaiwanFuturesNightInstitutionalInvestors': 'lite',
|
|
74
|
+
'TaiwanFuturesSettlement': 'lite',
|
|
75
|
+
'TaiwanFuturesSpread': 'lite',
|
|
76
|
+
'TaiwanFuturesTradeTick': 'free',
|
|
77
|
+
'TaiwanIndex5Seconds': 'plus',
|
|
78
|
+
'TaiwanIndexRealtime': 'plus',
|
|
79
|
+
'TaiwanMonthlyEconomicIndicator': 'lite',
|
|
80
|
+
'TaiwanOptionDaily': 'free',
|
|
81
|
+
'TaiwanOptionInstitutionalInvestors': 'free',
|
|
82
|
+
'TaiwanOptionsBrokerDaily': 'lite',
|
|
83
|
+
'TaiwanOptionsFSP': 'free',
|
|
84
|
+
'TaiwanOptionsLargeTraders': 'lite',
|
|
85
|
+
'TaiwanOptionsNightInstitutionalInvestors': 'lite',
|
|
86
|
+
'TaiwanOptionsSettlement': 'lite',
|
|
87
|
+
'TaiwanOptionsTradeTick': 'free',
|
|
88
|
+
'TaiwanRelatedNews': 'free',
|
|
89
|
+
'TaiwanReturnIndex': 'free',
|
|
90
|
+
'TaiwanStock10Year': 'lite',
|
|
91
|
+
'TaiwanStock5SecondsOrderStats': 'plus',
|
|
92
|
+
'TaiwanStockAnalystEstimate': 'plus',
|
|
93
|
+
'TaiwanStockBalanceSheet': 'free',
|
|
94
|
+
'TaiwanStockBlockTrade': 'free',
|
|
95
|
+
'TaiwanStockBorrowingCollateral': 'free',
|
|
96
|
+
'TaiwanStockBrokerInfo': 'free',
|
|
97
|
+
'TaiwanStockCapitalReductionReferencePrice': 'free',
|
|
98
|
+
'TaiwanStockCashFlowsStatement': 'free',
|
|
99
|
+
'TaiwanStockChangeFaceReference': 'free',
|
|
100
|
+
'TaiwanStockCompanyIndustry': 'lite',
|
|
101
|
+
'TaiwanStockCreditLimit': 'free',
|
|
102
|
+
'TaiwanStockDailyPriceLimit': 'lite',
|
|
103
|
+
'TaiwanStockDayTradingFee': 'lite',
|
|
104
|
+
'TaiwanStockDayTradingInfo': 'free',
|
|
105
|
+
'TaiwanStockDelisting': 'free',
|
|
106
|
+
'TaiwanStockDisposition': 'lite',
|
|
107
|
+
'TaiwanStockDividend': 'free',
|
|
108
|
+
'TaiwanStockDividendResult': 'free',
|
|
109
|
+
'TaiwanStockFinancialStatement': 'free',
|
|
110
|
+
'TaiwanStockHoldingSharesPer': 'lite',
|
|
111
|
+
'TaiwanStockInstitutionalInvestorsBuySell': 'lite',
|
|
112
|
+
'TaiwanStockKBar1Min': 'plus',
|
|
113
|
+
'TaiwanStockMarginPurchaseShortSale': 'lite',
|
|
114
|
+
'TaiwanStockMarginRatio': 'lite',
|
|
115
|
+
'TaiwanStockMarginShortSaleSuspension': 'lite',
|
|
116
|
+
'TaiwanStockMarketValue': 'lite',
|
|
117
|
+
'TaiwanStockMarketValueWeight': 'lite',
|
|
118
|
+
'TaiwanStockMonthPrice': 'lite',
|
|
119
|
+
'TaiwanStockMonthRevenue': 'lite',
|
|
120
|
+
'TaiwanStockOverview': 'free',
|
|
121
|
+
'TaiwanStockOverviewIncludeWarrant': 'free',
|
|
122
|
+
'TaiwanStockPER': 'free',
|
|
123
|
+
'TaiwanStockPrice': 'lite',
|
|
124
|
+
'TaiwanStockPriceAdj': 'lite',
|
|
125
|
+
'TaiwanStockRealtime': 'plus',
|
|
126
|
+
'TaiwanStockSectorPrice': 'free',
|
|
127
|
+
'TaiwanStockSecuritiesLending': 'free',
|
|
128
|
+
'TaiwanStockShareholding': 'lite',
|
|
129
|
+
'TaiwanStockSplitPrice': 'free',
|
|
130
|
+
'TaiwanStockSuspendDayTrading': 'lite',
|
|
131
|
+
'TaiwanStockTotalInstitutionalInvestors': 'free',
|
|
132
|
+
'TaiwanStockTotalMarginPurchaseShortSale': 'free',
|
|
133
|
+
'TaiwanStockTradingDate': 'free',
|
|
134
|
+
'TaiwanStockTradingHaltAnnouncement': 'lite',
|
|
135
|
+
'TaiwanStockWarrantInfo': 'free',
|
|
136
|
+
'TaiwanStockWeekPrice': 'lite',
|
|
137
|
+
'TaiwanWeightedIndex': 'free',
|
|
138
|
+
'USBondYield': 'free',
|
|
139
|
+
'USStockKBars': 'plus',
|
|
140
|
+
'USStockPrice': 'free',
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ALL: frozenset[str] = frozenset(DATASET_TIERS)
|
|
144
|
+
|
|
145
|
+
# expose each dataset name as a module attribute: datasets.TaiwanStockPrice
|
|
146
|
+
for _n in DATASET_TIERS:
|
|
147
|
+
globals()[_n] = _n
|
|
148
|
+
del _n
|
clarifindata/errors.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Typed exceptions with actionable suggestions (instead of raw httpx errors)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ClarifindataError(Exception):
|
|
6
|
+
"""Base error. Carries the dataset, HTTP status, server detail and a hint."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, message: str, *, dataset: str | None = None,
|
|
9
|
+
status: int | None = None, detail: str | None = None,
|
|
10
|
+
suggestion: str | None = None) -> None:
|
|
11
|
+
self.dataset = dataset
|
|
12
|
+
self.status = status
|
|
13
|
+
self.detail = detail
|
|
14
|
+
self.suggestion = suggestion
|
|
15
|
+
parts = [message]
|
|
16
|
+
if dataset:
|
|
17
|
+
parts.append(f"dataset={dataset}")
|
|
18
|
+
if status:
|
|
19
|
+
parts.append(f"HTTP {status}")
|
|
20
|
+
if detail:
|
|
21
|
+
parts.append(f"detail={detail}")
|
|
22
|
+
if suggestion:
|
|
23
|
+
parts.append(f"\n → {suggestion}")
|
|
24
|
+
super().__init__(" ".join(parts))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthError(ClarifindataError):
|
|
28
|
+
"""401/403 — bad/missing key, or the dataset needs a higher tier."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TierError(AuthError):
|
|
32
|
+
"""403 — your tier can't access this dataset/endpoint."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RateLimitError(ClarifindataError):
|
|
36
|
+
"""429 — rate limit hit (after retries were exhausted)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, *, retry_after: float | None = None, **kw) -> None:
|
|
39
|
+
self.retry_after = retry_after
|
|
40
|
+
super().__init__(message, **kw)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NotFoundError(ClarifindataError):
|
|
44
|
+
"""404 — unknown dataset/endpoint."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ValidationError(ClarifindataError):
|
|
48
|
+
"""400/422 — bad parameters (e.g. dataset typo, limit > 10000)."""
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clarifindata
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: Python client for the 金晰數據 / clarifindata Taiwan-stock data API
|
|
5
|
+
Project-URL: Homepage, https://clarifindata.com
|
|
6
|
+
Project-URL: Documentation, https://clarifindata.com/docs
|
|
7
|
+
Project-URL: Source, https://github.com/AChao0212/clarifindata-python
|
|
8
|
+
Author: clarifindata
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: data,finance,quant,stock,taiwan,tpex,twse
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Requires-Dist: httpx>=0.24
|
|
14
|
+
Provides-Extra: all
|
|
15
|
+
Requires-Dist: h2>=4.0; extra == 'all'
|
|
16
|
+
Requires-Dist: pandas>=1.5; extra == 'all'
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pandas>=1.5; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
20
|
+
Provides-Extra: http2
|
|
21
|
+
Requires-Dist: h2>=4.0; extra == 'http2'
|
|
22
|
+
Provides-Extra: pandas
|
|
23
|
+
Requires-Dist: pandas>=1.5; extra == 'pandas'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# clarifindata — Python client
|
|
27
|
+
|
|
28
|
+
Python client for the **金晰數據 / clarifindata** Taiwan-stock data API (89 datasets:
|
|
29
|
+
prices, chips, fundamentals, derivatives, macro — full history, refreshed daily).
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install clarifindata # core
|
|
33
|
+
pip install 'clarifindata[pandas]' # + DataFrame output (as_df=True)
|
|
34
|
+
pip install 'clarifindata[all]' # + pandas + HTTP/2
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from clarifindata import Client, datasets
|
|
41
|
+
|
|
42
|
+
cfd = Client(api_key="cfd_demo_free_0001") # free demo key
|
|
43
|
+
|
|
44
|
+
# typed dataset names (autocomplete + typo-safe), DataFrame output
|
|
45
|
+
df = cfd.get(datasets.TaiwanStockPrice, stock_id="2330",
|
|
46
|
+
start="2026-04-01", end="2026-06-03", as_df=True)
|
|
47
|
+
print(df.head())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Demo keys** (rate-limited, for trying out): `cfd_demo_free_0001` (Free),
|
|
51
|
+
`cfd_demo_lite_0001` (Lite). Get your own at <https://clarifindata.com/signup>.
|
|
52
|
+
|
|
53
|
+
## Features
|
|
54
|
+
|
|
55
|
+
| What | How |
|
|
56
|
+
|------|-----|
|
|
57
|
+
| **Auto-retry** 429/5xx/timeouts (honours `Retry-After`, exp backoff) | on by default; tune `max_retries`, `backoff_base` |
|
|
58
|
+
| **Rate-limit aware** | `cfd.rate_limit_remaining`; warns at 80% used |
|
|
59
|
+
| **DataFrame output** | `cfd.get(..., as_df=True)` |
|
|
60
|
+
| **Full-history streaming** (auto-paginates the 10 000-row cap) | `cfd.iter_history(ds, stock_id=..., since="2015-01-01")` |
|
|
61
|
+
| **Async / concurrency** | `AsyncClient` (same surface) |
|
|
62
|
+
| **Multi-dataset pull** | `cfd.bulk_pull([...], start=, end=)` |
|
|
63
|
+
| **Disk cache** (for slow-changing datasets) | `Client(cache_dir="./.cache", cache_ttl=86400)` |
|
|
64
|
+
| **Typed errors w/ suggestions** | `ClarifindataError(dataset, status, detail, suggestion)` |
|
|
65
|
+
| **Logging** | `logging.getLogger("clarifindata")` (DEBUG = request/response) |
|
|
66
|
+
|
|
67
|
+
## Full history (any range, no manual chunking)
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
for batch in cfd.iter_history(datasets.TaiwanStockPrice, stock_id="2330", since="2015-01-01"):
|
|
71
|
+
process(batch) # one year per batch, streamed
|
|
72
|
+
|
|
73
|
+
df = cfd.history(datasets.TaiwanStockPrice, stock_id="2330", since="2015-01-01", as_df=True)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Concurrency (fetch many stocks)
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import asyncio
|
|
80
|
+
from clarifindata import AsyncClient, datasets
|
|
81
|
+
|
|
82
|
+
async def main():
|
|
83
|
+
async with AsyncClient(api_key="...") as cfd:
|
|
84
|
+
tasks = [cfd.get(datasets.TaiwanStockPrice, stock_id=s, as_df=True)
|
|
85
|
+
for s in ("2330", "2317", "2454")]
|
|
86
|
+
return await asyncio.gather(*tasks)
|
|
87
|
+
|
|
88
|
+
frames = asyncio.run(main())
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Natural-language queries (Plus tier)
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
ans = cfd.ask("找出 2330 在 2026 年 4 月最高收盤價那天", as_df=True)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Errors
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from clarifindata import TierError, RateLimitError, ValidationError
|
|
101
|
+
try:
|
|
102
|
+
cfd.get("TaiwanStockPirce") # typo
|
|
103
|
+
except ValidationError as e:
|
|
104
|
+
print(e) # Unknown dataset 'TaiwanStockPirce'. Did you mean 'TaiwanStockPrice'? ...
|
|
105
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
clarifindata/__init__.py,sha256=qk3A7-ohMU8gCUsNqmGTmQCdJN_tmLcbpb1drdncSmo,1122
|
|
2
|
+
clarifindata/client.py,sha256=Ie3aZbxlLvX0LqlNjH3Y-uytFUlkXKnXzXcG2xGkMmU,22546
|
|
3
|
+
clarifindata/datasets.py,sha256=rb2nCgDdpkstYwMOieUsMjKgXYSSSQelHMqHVR77BqQ,5802
|
|
4
|
+
clarifindata/errors.py,sha256=07qhYhXnY7uU4bq78VnKWE4j5typG4SIq7oIOdB4weQ,1581
|
|
5
|
+
clarifindata-0.2.5.dist-info/METADATA,sha256=4JXr4ehRVjAt6pXeJQjx0WLtetgRzQJo7mrNJwyPMd0,3691
|
|
6
|
+
clarifindata-0.2.5.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
clarifindata-0.2.5.dist-info/licenses/LICENSE,sha256=hvrW2KkWGXpSwgcZeVLzDNS3JnLvO6RJ4gtmZ60uNmk,1069
|
|
8
|
+
clarifindata-0.2.5.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 clarifindata
|
|
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.
|