SPB Git

Repository Public Research preview

client.py 9.4 KB 226 lines python

1"""Typed, redacting client for the FinData OS API (https://findata.spboucher.ai/api/v1).23Contract verified against the live OpenAPI on 2026-11-09 (see docs/findata_audit.md). Rules: bearer token from the4environment or the owner's secret file only (never logged, never in URLs); process-wide token bucket (default 5 req/s;5the server allows 600 req/min per key); bounded exponential backoff with jitter on 429/5xx; every call appends a6provenance record (method, path, status, X-Request-ID, X-Dataset-Version, elapsed) to a JSONL log; exports are hashed on7disk. Nothing here prints bar values.8"""9from __future__ import annotations1011import hashlib12import json13import os14import random15import re16import threading17import time18from dataclasses import asdict, dataclass19from datetime import datetime, timezone20from pathlib import Path21from typing import Any2223import httpx24import pyarrow as pa25import pyarrow.ipc as ipc2627_TOKEN_RE = re.compile(r"fd_[A-Za-z0-9_.\-]+")28DEFAULT_BASE = "https://findata.spboucher.ai/api/v1"29SECRET_CANDIDATES = ["~/findata-secrets/api-key-wp46.txt", "~/findata-secrets/api-key-claude-code.txt",30                     "~/Desktop/Cluster/secrets/findata/api-key-claude-code.txt"]31MAX_DAYS_PER_CALL = {"1m": 31, "5m": 186, "30m": 3650, "1h": 3650, "1d": 3650}323334def redact(text: Any) -> str:35    return _TOKEN_RE.sub("fd_<REDACTED>", str(text))363738class FinDataError(RuntimeError):39    def __init__(self, status: int, code: str, message: str, request_id: str | None = None):40        super().__init__(f"HTTP {status} {code}: {redact(message)} (request_id={request_id})")41        self.status, self.code, self.request_id = status, code, request_id424344def load_api_key() -> str:45    key = os.environ.get("FINDATA_API_KEY")46    if key:47        return key.strip()48    f = os.environ.get("FINDATA_API_KEY_FILE")49    for c in ([f] if f else []) + SECRET_CANDIDATES:50        p = Path(os.path.expanduser(c))51        if p.exists():52            return p.read_text().strip()53    raise FileNotFoundError("FinData API key not found (set FINDATA_API_KEY or FINDATA_API_KEY_FILE)")545556def base_url() -> str:57    return os.environ.get("FINDATA_BASE_URL", DEFAULT_BASE).rstrip("/")585960class _RateLimiter:61    """Process-wide token bucket shared by every client/thread (override with WP46_RATE requests per second)."""62    _lock = threading.Lock()63    _last = 0.06465    @classmethod66    def wait(cls) -> None:67        rate = float(os.environ.get("WP46_RATE", "5"))68        if rate <= 0:69            return70        with cls._lock:71            gap = 1.0 / rate - (time.monotonic() - cls._last)72            if gap > 0:73                time.sleep(gap)74            cls._last = time.monotonic()757677@dataclass78class Provenance:79    ts: str80    method: str81    path: str82    status: int83    request_id: str | None84    dataset_version: str | None85    elapsed_ms: int86    note: str | None = None878889def sha256_file(path: str | Path) -> str:90    h = hashlib.sha256()91    with open(path, "rb") as fh:92        for chunk in iter(lambda: fh.read(1 << 20), b""):93            h.update(chunk)94    return h.hexdigest()959697class FinDataClient:98    def __init__(self, base: str | None = None, api_key: str | None = None, log_path: str | Path | None = None,99                 timeout: float = 300.0, max_retries: int = 6):100        self.base = (base or base_url()).rstrip("/")101        self._key = api_key or load_api_key()102        self.log_path = Path(log_path) if log_path else None103        self.max_retries = max_retries104        self._client = httpx.Client(base_url=self.base, timeout=timeout,105                                    headers={"Authorization": f"Bearer {self._key}", "User-Agent": "wp46-garch-benchmark/0.1"})106        self._lock = threading.Lock()107108    # ------------------------------------------------------------------ low level109    def _log(self, rec: Provenance) -> None:110        if not self.log_path:111            return112        self.log_path.parent.mkdir(parents=True, exist_ok=True)113        with self._lock, open(self.log_path, "a") as fh:114            fh.write(json.dumps(asdict(rec)) + "\n")115116    def request(self, method: str, path: str, *, params: dict | None = None, json_body: dict | None = None,117                headers: dict | None = None, note: str | None = None) -> httpx.Response:118        attempt = 0119        while True:120            _RateLimiter.wait()121            t0 = time.monotonic()122            try:123                r = self._client.request(method, path, params=params, json=json_body, headers=headers)124            except (httpx.TransportError, httpx.TimeoutException) as e:  # network: retry125                attempt += 1126                if attempt > self.max_retries:127                    raise FinDataError(0, "transport", redact(e)) from None128                time.sleep(min(60.0, (2 ** attempt) + random.random()))129                continue130            el = int((time.monotonic() - t0) * 1000)131            self._log(Provenance(datetime.now(timezone.utc).isoformat(), method, path, r.status_code,132                                 r.headers.get("x-request-id"), r.headers.get("x-dataset-version"), el, note))133            if r.status_code in (429, 500, 502, 503, 504) and attempt < self.max_retries:134                attempt += 1135                ra = r.headers.get("retry-after")136                wait = float(ra) if ra and ra.replace(".", "", 1).isdigit() else min(60.0, (2 ** attempt) + random.random())137                time.sleep(wait)138                continue139            if r.status_code >= 400:140                try:141                    err = r.json().get("error", {})142                except Exception:143                    err = {}144                raise FinDataError(r.status_code, err.get("code", "http_error"), err.get("message", r.text[:300]),145                                   r.headers.get("x-request-id"))146            return r147148    def get(self, path: str, **params: Any) -> Any:149        return self.request("GET", path, params={k: v for k, v in params.items() if v is not None}).json()150151    def post(self, path: str, body: dict) -> Any:152        return self.request("POST", path, json_body=body).json()153154    # ------------------------------------------------------------------ catalog155    def health(self) -> dict:156        return self.get("/health")157158    def openapi(self) -> dict:159        return self.get("/openapi.json")160161    def datasets(self) -> list[dict]:162        return self.get("/catalog/datasets")["data"]163164    def dataset(self, dataset_id: str) -> dict:165        return self.get(f"/catalog/datasets/{dataset_id}")166167    def instrument(self, symbol: str) -> dict:168        return self.get(f"/instruments/{symbol}")169170    def bars_schema(self) -> dict:171        return self.get("/catalog/schema/bars")172173    # ------------------------------------------------------------------ data174    def sql(self, sql: str, max_rows: int = 10000) -> pa.Table:175        d = self.post("/query", {"sql": sql, "max_rows": max_rows})176        cols = [c["name"] for c in d["columns"]]177        rows = d["rows"]178        if not rows:179            return pa.table({c: [] for c in cols})180        if isinstance(rows[0], dict):181            return pa.Table.from_pylist(rows)182        return pa.table({c: [r[i] for r in rows] for i, c in enumerate(cols)})183184    def bars(self, symbol: str, interval: str, start: str, end: str, adjustment: str = "raw",185             limit: int = 10000) -> pa.Table:186        """All pages of GET /market/bars for one symbol/interval/range (range must respect MAX_DAYS_PER_CALL)."""187        tables: list[pa.Table] = []188        cursor = None189        while True:190            r = self.request("GET", "/market/bars", params={k: v for k, v in dict(191                symbol=symbol, interval=interval, start=start, end=end, adjustment=adjustment, limit=limit,192                cursor=cursor).items() if v is not None}, headers={"Accept": "application/vnd.apache.arrow.stream"})193            ctype = r.headers.get("content-type", "")194            if "arrow" in ctype:195                tables.append(ipc.open_stream(r.content).read_all())196            else:197                d = r.json()198                tables.append(pa.Table.from_pylist(d.get("data", [])))199            cursor = r.headers.get("x-next-cursor")200            if not cursor:201                break202        return pa.concat_tables([t for t in tables if t.num_rows]) if any(t.num_rows for t in tables) else tables[0]203204    def export(self, dataset_id: str, symbols: list[str], start: str, end: str, dest: str | Path,205               adjustment: str | None = None, poll: float = 2.0) -> dict:206        body: dict[str, Any] = {"dataset_id": dataset_id, "symbols": symbols, "start": start, "end": end}207        if adjustment:208            body["adjustment"] = adjustment209        job = self.post("/exports", body)210        eid = job["id"]211        while True:212            st = self.get(f"/exports/{eid}")213            if st["status"] == "ready":214                break215            if st["status"] in ("failed", "expired"):216                raise FinDataError(500, "export_" + st["status"], str(st.get("error")))217            time.sleep(poll)218        dest = Path(dest)219        dest.parent.mkdir(parents=True, exist_ok=True)220        r = self.request("GET", f"/exports/{eid}/download")221        dest.write_bytes(r.content)222        return {"export_id": eid, "dataset_id": dataset_id, "dataset_version": (st.get("snapshot") or {}).get("version"),223                "symbols": symbols, "start": start, "end": end, "row_count": st.get("row_count"),224                "bytes": st.get("bytes"), "sha256": sha256_file(dest), "path": str(dest),225                "retrieved_at": datetime.now(timezone.utc).isoformat()}226
Files main
Menu

Theme