1"""FinData OS Python SDK.23 from findata_sdk import FinData4 fd = FinData("https://findata.spboucher.ai", api_key="fd_xxx.yyy")5 bars = fd.bars("AAPL", "1m", "2026-10-26", "2026-10-30") # pyarrow.Table (all pages, Arrow IPC)6 df = bars.to_pandas() # or fd.bars(...).to_polars()7 export = fd.export(["AAPL", "MSFT"], "2024-01-01", "2026-10-30") # async Parquet export → local file8 facts = fd.fundamentals("AAPL", as_of="2025-01-01T00:00:00Z", metrics=["rev", "net_inc"])9"""10from __future__ import annotations1112import io13import os14import time15from datetime import date, datetime16from pathlib import Path17from typing import Any1819import httpx20import pyarrow as pa21import pyarrow.ipc as ipc222324class FinDataError(RuntimeError):25 def __init__(self, status: int, payload: Any):26 self.status = status27 self.payload = payload28 err = payload.get("error", {}) if isinstance(payload, dict) else {}29 super().__init__(f"HTTP {status} {err.get('code', '')}: {err.get('message', payload)} (request_id={err.get('request_id')})")303132class FinData:33 def __init__(self, base_url: str | None = None, api_key: str | None = None, timeout: float = 120.0, transport: httpx.BaseTransport | None = None):34 self.base_url = (base_url or os.environ.get("FINDATA_URL", "https://findata.spboucher.ai")).rstrip("/")35 key = api_key or os.environ.get("FINDATA_API_KEY")36 if not key:37 raise ValueError("api_key required (or FINDATA_API_KEY env var)")38 self._c = httpx.Client(base_url=self.base_url, timeout=timeout, headers={"Authorization": f"Bearer {key}", "User-Agent": "findata-sdk/0.1"}, transport=transport)3940 # ------------------------------------------------------------------ low level41 def _get(self, path: str, **params: Any) -> Any:42 r = self._c.get(f"/api/v1{path}", params={k: v for k, v in params.items() if v is not None})43 if r.status_code >= 400:44 raise FinDataError(r.status_code, _json(r))45 return r.json()4647 def _post(self, path: str, body: dict[str, Any]) -> Any:48 r = self._c.post(f"/api/v1{path}", json=body)49 if r.status_code >= 400:50 raise FinDataError(r.status_code, _json(r))51 return r.json()5253 # ------------------------------------------------------------------ catalog54 def datasets(self) -> list[dict[str, Any]]:55 return self._get("/catalog/datasets")["data"]5657 def instruments(self, query: str = "", **kw: Any) -> list[dict[str, Any]]:58 return self._get("/instruments", query=query, **kw)["data"]5960 def health(self) -> dict[str, Any]:61 return self._get("/health")6263 # ------------------------------------------------------------------ bars64 def bars(self, symbol: str, interval: str, start: str | date, end: str | date, adjustment: str = "raw",65 max_pages: int = 200) -> pa.Table:66 """All pages of a bounded request as one Arrow table (Arrow IPC transport, cursor pagination)."""67 tables: list[pa.Table] = []68 cursor: str | None = None69 for _ in range(max_pages):70 params = {"symbol": symbol, "interval": interval, "start": str(start), "end": str(end), "adjustment": adjustment,71 "format": "arrow", "limit": 10000}72 if cursor:73 params["cursor"] = cursor74 r = self._c.get("/api/v1/market/bars", params=params)75 if r.status_code >= 400:76 raise FinDataError(r.status_code, _json(r))77 with ipc.open_stream(io.BytesIO(r.content)) as reader:78 tables.append(reader.read_all())79 cursor = r.headers.get("X-Next-Cursor") or None80 if not cursor:81 break82 return pa.concat_tables(tables) if tables else pa.table({})8384 def sessions(self, symbol: str, interval: str = "1m", **kw: Any) -> list[dict[str, Any]]:85 return self._get("/market/sessions", symbol=symbol, interval=interval, **kw)["data"]8687 # ------------------------------------------------------------------ fundamentals88 def fundamentals(self, entity: str, as_of: str | datetime | None = None, metrics: list[str] | None = None,89 period_type: str | None = None) -> dict[str, Any]:90 return self._get(f"/fundamentals/{entity}", as_of=str(as_of) if as_of else None, metrics=",".join(metrics) if metrics else None,91 period_type=period_type)9293 # ------------------------------------------------------------------ query94 def sql(self, sql: str, max_rows: int = 1000) -> pa.Table:95 res = self._post("/query", {"sql": sql, "max_rows": max_rows})96 if not res["rows"]:97 return pa.table({c["name"]: [] for c in res["columns"]})98 return pa.Table.from_pylist(res["rows"])99100 # ------------------------------------------------------------------ exports101 def export(self, symbols: list[str], start: str | date, end: str | date, dataset_id: str = "market_bars.stock.1m.raw",102 adjustment: str = "raw", fmt: str = "parquet", dest: str | Path | None = None, poll_s: float = 2.0, timeout_s: float = 3600) -> Path:103 job = self._post("/exports", {"dataset_id": dataset_id, "symbols": symbols, "start": str(start), "end": str(end), "adjustment": adjustment, "format": fmt})104 eid = job["id"]105 t0 = time.monotonic()106 while True:107 st = self._get(f"/exports/{eid}")108 if st["status"] == "ready":109 break110 if st["status"] in ("failed", "expired"):111 raise FinDataError(500, {"error": {"code": st["status"], "message": st.get("error")}})112 if time.monotonic() - t0 > timeout_s:113 raise TimeoutError(f"export {eid} not ready after {timeout_s}s")114 time.sleep(poll_s)115 out = Path(dest) if dest else Path(f"findata-{eid}.{fmt}")116 with self._c.stream("GET", f"/api/v1/exports/{eid}/download") as r:117 if r.status_code >= 400:118 raise FinDataError(r.status_code, {"error": {"code": "download_failed"}})119 with open(out, "wb") as f:120 for chunk in r.iter_bytes():121 f.write(chunk)122 return out123124125def _json(r: httpx.Response) -> Any:126 try:127 return r.json()128 except Exception: # noqa: BLE001129 return {"error": {"code": "http", "message": r.text[:300]}}130