SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%
7.0 KB · 158 lines python
Raw Blame History
1"""Tiny HF Market Data client shared by the hfmd-* skills (requests + pandas only).23Copied verbatim into every skill's `scripts/` folder by `skills/scripts/build_skills.py`4so each skill stays self-contained. Do not edit the copies — edit `skills/_shared/hfmd.py`.56Environment: HFMD_API_KEY (optional; free API key = 120 req/min, keyless mode has low hourly limits = 30 req/h;7             higher limits on request by e-mail to contact@spboucher.ai, also free),8             HFMD_BASE_URL (default https://www.hfmarketdata.io).9"""10from __future__ import annotations1112import os13import sys14import time15from typing import Any1617import pandas as pd18import requests1920BASE_URL = os.environ.get("HFMD_BASE_URL", "https://www.hfmarketdata.io").rstrip("/")21API_KEY = os.environ.get("HFMD_API_KEY", "").strip()22UA = "hfmd-skills/1.0 (+https://www.hfmarketdata.io/integrations/skills)"2324TIMEFRAMES = {"1m": "1min", "5m": "5min", "30m": "30min", "1h": "1hour", "1d": "1day",25              "1min": "1min", "5min": "5min", "30min": "30min", "1hour": "1hour", "1day": "1day"}26V2_INTERVAL = {"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"}272829class HfmdError(RuntimeError):30    def __init__(self, status: int, code: str, message: str, url: str):31        super().__init__(f"HF Market Data error {status} {code}: {message} ({url})")32        self.status, self.code, self.url = status, code, url333435_session = requests.Session()36_session.headers.update({"Accept": "application/json", "User-Agent": UA})37if API_KEY:38    _session.headers["Authorization"] = f"Bearer {API_KEY}"394041def log(msg: str) -> None:42    print(msg, file=sys.stderr)434445def get(path: str, params: dict[str, Any] | None = None, *, retries: int = 3) -> tuple[Any, dict[str, str]]:46    """GET a JSON endpoint. Returns (body, headers). Retries on 429 honouring Retry-After."""47    url = f"{BASE_URL}{path}"48    p = {k: (",".join(v) if isinstance(v, (list, tuple)) else v) for k, v in (params or {}).items() if v not in (None, "")}49    p.setdefault("format", "json")50    for attempt in range(retries + 1):51        r = _session.get(url, params=p, timeout=120)52        if r.status_code == 429 and attempt < retries:53            wait = float(r.headers.get("Retry-After", "5"))54            log(f"429 rate limited — sleeping {wait:.0f}s (set HFMD_API_KEY for 120 req/min)")55            time.sleep(min(wait, 120))56            continue57        if r.status_code >= 400:58            try:59                body = r.json()60            except ValueError:61                body = {}62            err = body.get("error") or {}63            raise HfmdError(r.status_code, err.get("code", "HTTP_ERROR"), err.get("message") or body.get("detail") or r.text[:200], r.url)64        rem = r.headers.get("X-RateLimit-Remaining-Requests")65        if rem is not None:66            log(f"rate limit: {rem}/{r.headers.get('X-RateLimit-Limit-Requests')} requests left")67        return r.json(), dict(r.headers)68    raise HfmdError(429, "RATE_LIMIT_EXCEEDED", "gave up after retries", url)697071def rows_of(body: Any) -> list[dict]:72    """Rows from either envelope: v1 {count,data} or v2 {data,meta}."""73    if isinstance(body, list):74        return body75    if isinstance(body, dict):76        data = body.get("data")77        if isinstance(data, list):78            return data79    return []808182def meta_of(body: Any) -> dict:83    if isinstance(body, dict):84        m = dict(body.get("meta") or {})85        if "count" in body and "count" not in m:86            m["count"] = body["count"]87        return m88    return {}899091def to_df(body: Any, time_col: str | None = "datetime") -> pd.DataFrame:92    df = pd.DataFrame(rows_of(body))93    if time_col and time_col in df.columns:94        df[time_col] = pd.to_datetime(df[time_col])95        df = df.sort_values(time_col).reset_index(drop=True)96    return df979899def bars(asset: str, ticker: str, timeframe: str = "1day", start: str | None = None, end: str | None = None,100         adjustment: str | None = None, limit: int = 50_000) -> pd.DataFrame:101    """v1 bars (stock/etf/crypto/index/fx, or futures = vendor continuous). Paginates by date when needed."""102    tf = TIMEFRAMES.get(timeframe, timeframe)103    frames: list[pd.DataFrame] = []104    cursor_start = start105    while True:106        body, _ = get(f"/v1/bars/{asset}/{ticker}", {"timeframe": tf, "start": cursor_start, "end": end, "adjustment": adjustment, "limit": limit, "order": "asc"})107        df = to_df(body)108        if df.empty:109            break110        frames.append(df)111        if len(df) < limit:112            break113        last = df["datetime"].iloc[-1]114        cursor_start = (last + pd.Timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M") if tf != "1day" else (last + pd.Timedelta(days=1)).strftime("%Y-%m-%d")115    out = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["ticker", "datetime", "open", "high", "low", "close", "volume"])116    return out.drop_duplicates("datetime").reset_index(drop=True)117118119def contract_bars(symbol: str, timeframe: str = "1day", start: str | None = None, end: str | None = None, limit: int = 50_000) -> pd.DataFrame:120    """v2 individual futures contract bars (ESZ25…)."""121    tf = TIMEFRAMES.get(timeframe, timeframe)122    body, _ = get(f"/v1/futures/contract/{symbol}/bars", {"interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit})123    return to_df(body)124125126def continuous(root: str, roll: str = "volume", adjust: str = "back_adjusted", depth: int = 1, timeframe: str = "1day",127               start: str | None = None, end: str | None = None, limit: int = 50_000) -> tuple[pd.DataFrame, dict]:128    """v2 server-built continuous series. Returns (df, meta) — meta['roll_dates'] lists the rolls."""129    tf = TIMEFRAMES.get(timeframe, timeframe)130    body, _ = get(f"/v1/futures/{root}/continuous", {"roll": roll, "adjust": adjust, "depth": depth, "interval": V2_INTERVAL.get(tf, "1d"), "from": start, "to": end, "limit": limit})131    return to_df(body), meta_of(body)132133134def contracts(root: str, status: str | None = None) -> pd.DataFrame:135    body, _ = get(f"/v1/futures/{root}/contracts", {"status": status, "limit": 1000})136    return to_df(body, time_col=None)137138139def term_structure(root: str, as_of: str | None = None) -> tuple[pd.DataFrame, dict]:140    body, _ = get(f"/v1/futures/{root}/term-structure", {"as_of": as_of})141    return to_df(body, time_col=None), meta_of(body)142143144def screener(filters: str, sort: str | None = None, as_of: str | None = None, limit: int = 100) -> tuple[pd.DataFrame, dict]:145    body, _ = get("/v1/fundamentals/screener", {"filters": filters, "sort": sort, "as_of": as_of, "limit": limit})146    return to_df(body, time_col=None), meta_of(body)147148149def ratios(ticker: str) -> dict:150    body, _ = get(f"/v1/fundamentals/{ticker}/ratios")151    d = body.get("data", body) if isinstance(body, dict) else body152    return d if isinstance(d, dict) else (d[0] if d else {})153154155def statements(ticker: str, statement: str = "income", period: str = "quarterly", limit: int = 12) -> pd.DataFrame:156    body, _ = get(f"/v1/fundamentals/{ticker}/statements", {"statement": statement, "period": period, "limit": limit})157    return to_df(body, time_col=None)158