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.
@@ -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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.