SPB Git

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)

HF Market Data — FirstRate full-history downloader, DuckDB/Parquet lake, open API and docs platform

- frd_downloader.py: 745-job resumable plan (options 2010→today, stock/etf A-Z,
  futures continuous+contracts, crypto/index/fx, all timeframes & adjustments),
  CSV→Parquet(zstd) via DuckDB, raw archives deleted on the fly
- hfmarketdata/api: open FastAPI+DuckDB API (bars, multi-ticker, point-in-time
  snapshot, options chains/history, live status), JSON+CSV
- hfmarketdata/web: React+Vite documentation platform (editorial light theme)

Author: Simon-Pierre Boucher <contact@spboucher.ai>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed (Aug 10, 2026) Browse files

14 changed files +4,352 −0

added .gitignore +24 −0
@@ -0,0 +1,24 @@
1 +# --- heavy data: never in the repo ---
2 +*.zip
3 +*.parquet
4 +*.csv
5 +*.duckdb
6 +parquet/
7 +zips/
8 +data/
9 +state/
10 +logs/
11 +
12 +# credentials / vendor docs containing the customer id
13 +api.txt
14 +
15 +# environments & builds
16 +venv/
17 +__pycache__/
18 +*.pyc
19 +node_modules/
20 +hfmarketdata/web/dist/
21 +hfmarketdata/venv/
22 +
23 +# OS / editor
24 +.DS_Store
added README.md +114 −0
@@ -0,0 +1,114 @@
1 +# HF Market Data
2 +
3 +**Open high-frequency market data platform** — a full-history downloader, a DuckDB-over-Parquet data lake, an open REST API and a documentation platform, live at **[www.hfmarketdata.io](https://www.hfmarketdata.io)**.
4 +
5 +[![Live](https://img.shields.io/badge/live-www.hfmarketdata.io-2e5ce6)](https://www.hfmarketdata.io)
6 +[![API](https://img.shields.io/badge/API-open%20%C2%B7%20no%20key-2e5ce6)](https://www.hfmarketdata.io/docs)
7 +[![Python](https://img.shields.io/badge/Python-3.12%2B-3776AB?logo=python&logoColor=white)](#)
8 +[![FastAPI](https://img.shields.io/badge/FastAPI-0.115%2B-009688?logo=fastapi&logoColor=white)](#)
9 +[![DuckDB](https://img.shields.io/badge/DuckDB-1.x-FFF000?logo=duckdb&logoColor=black)](#)
10 +[![React](https://img.shields.io/badge/React-18-61DAFB?logo=react&logoColor=black)](#)
11 +[![Vite](https://img.shields.io/badge/Vite-6-646CFF?logo=vite&logoColor=white)](#)
12 +[![Parquet](https://img.shields.io/badge/storage-Parquet%20%C2%B7%20zstd-50ABF1)](#)
13 +
14 +[![Author](https://img.shields.io/badge/author-Simon--Pierre%20Boucher-1c2430)](mailto:contact@spboucher.ai)
15 +[![Contact](https://img.shields.io/badge/contact-contact%40spboucher.ai-1c2430)](mailto:contact@spboucher.ai)
16 +
17 +---
18 +
19 +## Dataset metrics
20 +
21 +Live snapshot of the lake while ingestion is still running (2026-08-10):
22 +
23 +| Metric | Value |
24 +|---|---:|
25 +| Rows converted to Parquet | **1,785,837,666+** |
26 +| Parquet files (one per instrument/segment) | **211,140+** |
27 +| Lake size (zstd Parquet) | **34 GB+** (raw archives deleted on the fly) |
28 +| Options quarters ingested | **30+ / 66** (2010_q1 → today) |
29 +| Options underlyings | **5,800+** US equities & indices |
30 +| Bar timeframes | 1min · 5min · 30min · 1hour · 1day |
31 +| Asset classes | stocks · ETFs · futures (continuous + contracts) · crypto · indices · FX · options |
32 +| Download plan | 745 jobs, fully resumable, 0 conversion errors |
33 +
34 +Query the live inventory anytime: [`GET /v1/status`](https://www.hfmarketdata.io/v1/status).
35 +
36 +## Architecture
37 +
38 +```
39 +FirstRate Data API ──▶ frd_downloader.py ──▶ Parquet lake (zstd, one file per instrument)
40 + (zip archives) · 745-job plan /parquet/{type}/{timeframe}/{adjustment}/{TICKER}.parquet
41 + · resume manifest /parquet/options/{year}_{quarter}/…
42 + · CSV→Parquet via
43 + DuckDB, raw deleted
44 + │ queried in place (predicate pushdown)
45 + ▼
46 + hfmarketdata/api/main.py (FastAPI + DuckDB)
47 + │ :8090
48 + hfmarketdata/web/ (React + Vite docs platform)
49 + │
50 + https://www.hfmarketdata.io (ngrok)
51 +```
52 +
53 +## Repository layout
54 +
55 +```
56 +frd_downloader.py Full-history downloader & Parquet converter (single file)
57 +requirements.txt Downloader dependencies (requests, duckdb)
58 +hfmarketdata/
59 + api/main.py Open REST API — FastAPI + DuckDB over the Parquet lake
60 + requirements.txt API dependencies
61 + web/ Documentation platform (React 18 + Vite, English)
62 +```
63 +
64 +Heavy data (Parquet lake, zip archives, venvs, builds) is **not** in this repository — see `.gitignore`.
65 +
66 +## The downloader
67 +
68 +Downloads **everything** FirstRate Data publishes and stores it query-ready:
69 +
70 +- **Options** — every quarterly archive since 2010 + the current partial quarter (16 fields per row: quotes, bid/ask IV, open interest, volume, delta, gamma, vega, theta, rho)
71 +- **Stocks & ETFs** — full history, tickers A–Z, all 5 timeframes × `adj_split` / `adj_splitdiv` / `UNADJUSTED`
72 +- **Futures** — continuous series (`contin_UNadj` / `contin_adj_ratio` / `contin_adj_absolute`) + individual contracts
73 +- **Crypto, indices, FX** — full history, all timeframes
74 +- **Metadata** — ticker listings, splits, dividends, update logs, continuous-series audit
75 +
76 +```bash
77 +export FRD_USERID=<your FirstRate customer id>
78 +python3 -m venv venv && venv/bin/pip install -r requirements.txt
79 +venv/bin/python frd_downloader.py --data-root /Volumes/ssd/firstratedata
80 +```
81 +
82 +Fully resumable (`state/manifest.json`), disk-space guard, retries with backoff, and raw zips/CSVs are deleted as soon as each segment is converted. Notable quirks handled: FirstRate files mix LF/CRLF line endings (`strict_mode=false`), daily files use `yyyyMMdd` dates, the API 302-redirects to a CDN.
83 +
84 +## The API
85 +
86 +Open, keyless, JSON + CSV. Highlights:
87 +
88 +| Endpoint | What it does |
89 +|---|---|
90 +| `GET /v1/status` | Live inventory of the lake |
91 +| `GET /v1/{asset}/tickers` | Instrument discovery per type/timeframe/adjustment |
92 +| `GET /v1/bars/{asset}/{ticker}` | OHLCV bars, precise `start`/`end` windows, up to 2M rows in CSV |
93 +| `GET /v1/bars/{asset}?tickers=A,B,C` | Multi-ticker bars in one call (per-ticker limit) |
94 +| `GET /v1/snapshot/{asset}?tickers=…&at=…` | Whole watchlist at one precise moment |
95 +| `GET /v1/options/chain/{ticker}` | Full EOD chain — quotes, IV, Greeks |
96 +| `GET /v1/options/history/{ticker}` | One contract's daily life story |
97 +| `GET /docs` | OpenAPI / Swagger |
98 +
99 +```bash
100 +# 4 hours of 1-minute bars across a watchlist
101 +curl "https://www.hfmarketdata.io/v1/bars/stock?tickers=AAPL,MSFT,NVDA&timeframe=1min&start=2024-06-03%2009:30:00&end=2024-06-03%2013:30:00"
102 +```
103 +
104 +## The platform
105 +
106 +React 18 + Vite documentation site (English) served by the API process — refined editorial light theme (Fraunces / Inter / JetBrains Mono), full endpoint reference with parameter tables, request snippets (curl / Python / JavaScript), example + **live** responses, response-field dictionaries and a live dataset status page.
107 +
108 +```bash
109 +cd hfmarketdata/web && npm install && npm run build # → dist/, served at /
110 +```
111 +
112 +## Author
113 +
114 +**Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai)
added frd_downloader.py +627 −0
@@ -0,0 +1,627 @@
1 +#!/usr/bin/env python3
2 +# -*- coding: utf-8 -*-
3 +"""
4 +================================================================================
5 + FirstRate Data — Full Historical Downloader & Parquet Converter
6 +================================================================================
7 +
8 +Downloads the COMPLETE historical datasets from the FirstRate Data API and
9 +stores them as query-optimized Parquet files (zstd compressed), ready to be
10 +served by an ultra-fast query layer (DuckDB / pyarrow).
11 +
12 +Coverage
13 +--------
14 + * options : full archives per year/quarter (2010 -> today)
15 + + 'month' update for the current partial quarter
16 + * stock, etf : period=full, ticker_range A-Z,
17 + timeframes 1min/5min/30min/1hour/1day,
18 + adjustments adj_split / adj_splitdiv / UNADJUSTED
19 + (UNADJUSTED only exists for 1min & 1day)
20 + * futures (continuous) : timeframes x contin_UNadj / contin_adj_ratio /
21 + contin_adj_absolute
22 + * futures (individual) : contract_files=archive + update, all timeframes
23 + * crypto, index, fx : period=full, all timeframes
24 + * metadata : ticker listings, splits, dividends, update logs,
25 + futures continuous-series audit file
26 + * delisted stocks : optional (--include-delisted), archives 1-4 +
27 + 2026 update, all timeframe/adjustment combos
28 +
29 +Storage layout (under --data-root)
30 +----------------------------------
31 + parquet/{type}/{timeframe}/{adjustment}/{TICKER}.parquet (bar data)
32 + parquet/options/{year}_{quarter}/{file}.parquet (options)
33 + meta/… (csv metadata)
34 + zips/… (only w/ --keep-zips)
35 + state/manifest.json (resume state)
36 + logs/frd_downloader.log
37 +
38 +Every Parquet file carries a `ticker` column, so the whole tree can be
39 +queried directly, e.g.:
40 +
41 + SELECT * FROM read_parquet('parquet/stock/1day/adj_splitdiv/*.parquet')
42 + WHERE ticker = 'AAPL' AND datetime >= '2020-01-01';
43 +
44 +Usage
45 +-----
46 + python3 frd_downloader.py --data-root /Volumes/ssd/firstratedata
47 + python3 frd_downloader.py --list-jobs # preview the job plan
48 + python3 frd_downloader.py --types options stock # restrict asset types
49 + python3 frd_downloader.py --include-delisted # add delisted stocks
50 +
51 +The run is fully resumable: completed jobs are recorded in the manifest and
52 +skipped on re-run.
53 +
54 +Author : Simon-Pierre Boucher
55 +Contact : contact@spboucher.ai
56 +Created : 2026-08-09
57 +================================================================================
58 +"""
59 +
60 +from __future__ import annotations
61 +
62 +import argparse
63 +import json
64 +import logging
65 +import os
66 +import shutil
67 +import sys
68 +import tempfile
69 +import threading
70 +import time
71 +import zipfile
72 +from concurrent.futures import ThreadPoolExecutor, as_completed
73 +from dataclasses import dataclass, field
74 +from datetime import date
75 +from pathlib import Path
76 +from typing import Iterator
77 +from urllib.parse import urlencode
78 +
79 +import duckdb
80 +import requests
81 +
82 +__author__ = "Simon-Pierre Boucher"
83 +__contact__ = "contact@spboucher.ai"
84 +__version__ = "1.0.0"
85 +
86 +# ------------------------------------------------------------------------------
87 +# Configuration
88 +# ------------------------------------------------------------------------------
89 +
90 +BASE_URL = "https://firstratedata.com/api"
91 +# FirstRate Data customer id — never hardcoded: export FRD_USERID=<your id>
92 +USERID = os.environ.get("FRD_USERID", "")
93 +
94 +TIMEFRAMES = ["1day", "1hour", "30min", "5min", "1min"] # small -> large
95 +STOCK_ETF_ADJUSTMENTS = ["adj_split", "adj_splitdiv", "UNADJUSTED"]
96 +UNADJUSTED_TIMEFRAMES = {"1min", "1day"} # per API doc
97 +FUTURES_ADJUSTMENTS = ["contin_UNadj", "contin_adj_ratio", "contin_adj_absolute"]
98 +TICKER_RANGES = [chr(c) for c in range(ord("A"), ord("Z") + 1)]
99 +OPTIONS_FIRST_YEAR = 2010
100 +
101 +DOWNLOAD_TIMEOUT = 7200 # seconds per request (archives can be huge)
102 +CHUNK_SIZE = 4 * 1024 * 1024 # 4 MiB streaming chunks
103 +MAX_RETRIES = 5
104 +RETRY_BACKOFF = 60 # seconds, multiplied by attempt number
105 +MIN_FREE_GB = 30 # abort downloads below this free-space floor
106 +
107 +log = logging.getLogger("frd")
108 +
109 +# ------------------------------------------------------------------------------
110 +# Job model
111 +# ------------------------------------------------------------------------------
112 +
113 +
114 +@dataclass
115 +class Job:
116 + """One downloadable unit: an API request plus its destination."""
117 +
118 + key: str # unique id, used in the manifest
119 + endpoint: str # API endpoint path (e.g. 'data_file')
120 + params: dict # query parameters (userid added at request time)
121 + dest_subdir: str # where converted output lands, under data root
122 + kind: str = "bars" # 'bars' (zip of csv -> parquet) or 'meta' (raw text)
123 + priority: int = 50 # lower runs first
124 +
125 + @property
126 + def url(self) -> str:
127 + q = dict(self.params)
128 + q["userid"] = USERID
129 + return f"{BASE_URL}/{self.endpoint}?{urlencode(q)}"
130 +
131 + @property
132 + def display_url(self) -> str:
133 + return f"{BASE_URL}/{self.endpoint}?{urlencode(self.params)}&userid=***"
134 +
135 +
136 +def _current_quarter(today: date) -> tuple[int, int]:
137 + return today.year, (today.month - 1) // 3 + 1
138 +
139 +
140 +def build_jobs(types: list[str], include_delisted: bool) -> list[Job]:
141 + """Build the full download plan, ordered so that the small/most-useful
142 + datasets (daily bars, metadata) land first and the huge ones last."""
143 + jobs: list[Job] = []
144 + today = date.today()
145 +
146 + # -- metadata (tiny, always useful, run first) ------------------------------
147 + for t in ["stock", "etf", "futures", "crypto", "index", "fx"]:
148 + if t in types:
149 + jobs.append(Job(
150 + key=f"ticker_listing|{t}", endpoint="ticker_listing",
151 + params={"type": t}, dest_subdir=f"meta/{t}",
152 + kind="meta", priority=0))
153 + for t in ["stock", "etf"]:
154 + if t in types:
155 + for mft in ["splits", "dividends"]:
156 + jobs.append(Job(
157 + key=f"meta|{t}|{mft}", endpoint="meta_file",
158 + params={"type": t, "metafile_type": mft},
159 + dest_subdir=f"meta/{t}", kind="meta", priority=1))
160 + if "stock" in types:
161 + for lt in ["delisted", "added", "changed"]:
162 + jobs.append(Job(
163 + key=f"update_log|stock|{lt}", endpoint="update_log",
164 + params={"type": "stock", "log_type": lt},
165 + dest_subdir="meta/stock", kind="meta", priority=1))
166 + if "futures" in types:
167 + jobs.append(Job(
168 + key="meta|futures|contin_audit", endpoint="meta_file",
169 + params={"type": "futures", "metafile_type": "contin_audit"},
170 + dest_subdir="meta/futures", kind="meta", priority=1))
171 +
172 + # -- stock / etf : full archives, per letter --------------------------------
173 + for t in ["stock", "etf"]:
174 + if t not in types:
175 + continue
176 + for tf_i, tf in enumerate(TIMEFRAMES):
177 + for adj in STOCK_ETF_ADJUSTMENTS:
178 + if adj == "UNADJUSTED" and tf not in UNADJUSTED_TIMEFRAMES:
179 + continue
180 + for letter in TICKER_RANGES:
181 + jobs.append(Job(
182 + key=f"{t}|full|{tf}|{adj}|{letter}",
183 + endpoint="data_file",
184 + params={"type": t, "period": "full",
185 + "ticker_range": letter,
186 + "timeframe": tf, "adjustment": adj},
187 + dest_subdir=f"parquet/{t}/{tf}/{adj}",
188 + priority=10 + tf_i * 4))
189 +
190 + # -- futures : continuous series --------------------------------------------
191 + if "futures" in types:
192 + for tf_i, tf in enumerate(TIMEFRAMES):
193 + for adj in FUTURES_ADJUSTMENTS:
194 + jobs.append(Job(
195 + key=f"futures|full|{tf}|{adj}",
196 + endpoint="data_file",
197 + params={"type": "futures", "period": "full",
198 + "timeframe": tf, "adjustment": adj},
199 + dest_subdir=f"parquet/futures/{tf}/{adj}",
200 + priority=10 + tf_i * 4))
201 + # individual contracts (pre-2026 archive + current-year update)
202 + for tf_i, tf in enumerate(TIMEFRAMES):
203 + for cf in ["archive", "update"]:
204 + jobs.append(Job(
205 + key=f"futures_contract|{cf}|{tf}",
206 + endpoint="futures_contract",
207 + params={"contract_files": cf, "timeframe": tf},
208 + dest_subdir=f"parquet/futures_contracts/{tf}/{cf}",
209 + priority=12 + tf_i * 4))
210 +
211 + # -- crypto / index / fx : full archives -------------------------------------
212 + for t in ["crypto", "index", "fx"]:
213 + if t not in types:
214 + continue
215 + for tf_i, tf in enumerate(TIMEFRAMES):
216 + jobs.append(Job(
217 + key=f"{t}|full|{tf}",
218 + endpoint="data_file",
219 + params={"type": t, "period": "full", "timeframe": tf},
220 + dest_subdir=f"parquet/{t}/{tf}/none",
221 + priority=10 + tf_i * 4))
222 +
223 + # -- options : every quarter since 2010 + current partial quarter ------------
224 + # Priority 5-6: options are the primary dataset, they download right after
225 + # the metadata and before all the bar archives.
226 + if "options" in types:
227 + cur_year, cur_q = _current_quarter(today)
228 + for year in range(OPTIONS_FIRST_YEAR, cur_year + 1):
229 + for q in range(1, 5):
230 + if year == cur_year and q >= cur_q:
231 + continue # current/future quarters have no full archive yet
232 + jobs.append(Job(
233 + key=f"options|{year}|q{q}",
234 + endpoint="data_file",
235 + params={"type": "options", "year": str(year),
236 + "quarter": f"q{q}"},
237 + dest_subdir=f"parquet/options/{year}_q{q}",
238 + priority=5))
239 + jobs.append(Job(
240 + key="options|update|month",
241 + endpoint="data_file",
242 + params={"type": "options", "period": "month"},
243 + dest_subdir=f"parquet/options/{cur_year}_q{cur_q}_partial",
244 + priority=6))
245 +
246 + # -- delisted stocks (optional, survivorship-bias-free research) -------------
247 + if include_delisted and "stock" in types:
248 + for tf_i, tf in enumerate(TIMEFRAMES):
249 + for adj in STOCK_ETF_ADJUSTMENTS:
250 + if adj == "UNADJUSTED" and tf != "1min":
251 + continue # per API doc: UNADJUSTED delisted = 1min only
252 + for arc in ["1", "2", "3", "4"]:
253 + jobs.append(Job(
254 + key=f"delisted|archive{arc}|{tf}|{adj}",
255 + endpoint="delisted_data_file",
256 + params={"archive_number": arc,
257 + "timeframe": tf, "adjustment": adj},
258 + dest_subdir=f"parquet/stock_delisted/{tf}/{adj}",
259 + priority=30 + tf_i * 4))
260 + jobs.append(Job(
261 + key=f"delisted|update_year|{tf}|{adj}",
262 + endpoint="delisted_data_file",
263 + params={"update": "year", "timeframe": tf,
264 + "adjustment": adj},
265 + dest_subdir=f"parquet/stock_delisted/{tf}/{adj}",
266 + priority=30 + tf_i * 4))
267 +
268 + jobs.sort(key=lambda j: (j.priority, j.key))
269 + return jobs
270 +
271 +
272 +# ------------------------------------------------------------------------------
273 +# Manifest (resume state)
274 +# ------------------------------------------------------------------------------
275 +
276 +
277 +class Manifest:
278 + """Thread-safe JSON manifest tracking completed/failed jobs."""
279 +
280 + def __init__(self, path: Path):
281 + self.path = path
282 + self._lock = threading.Lock()
283 + self._data: dict = {"jobs": {}, "version": __version__}
284 + if path.exists():
285 + try:
286 + self._data = json.loads(path.read_text())
287 + except (json.JSONDecodeError, OSError):
288 + log.warning("Manifest unreadable, starting fresh: %s", path)
289 +
290 + def is_done(self, key: str) -> bool:
291 + return self._data["jobs"].get(key, {}).get("status") == "done"
292 +
293 + def mark(self, key: str, status: str, **info) -> None:
294 + with self._lock:
295 + entry = self._data["jobs"].setdefault(key, {})
296 + entry.update(status=status, updated=time.strftime("%Y-%m-%d %H:%M:%S"), **info)
297 + tmp = self.path.with_suffix(".tmp")
298 + tmp.write_text(json.dumps(self._data, indent=1))
299 + tmp.replace(self.path)
300 +
301 + def summary(self) -> dict:
302 + counts: dict[str, int] = {}
303 + for e in self._data["jobs"].values():
304 + counts[e.get("status", "?")] = counts.get(e.get("status", "?"), 0) + 1
305 + return counts
306 +
307 +
308 +# ------------------------------------------------------------------------------
309 +# CSV -> Parquet conversion
310 +# ------------------------------------------------------------------------------
311 +
312 +# Column layouts by column count (FirstRate bar files have no header row).
313 +# The first field is read as VARCHAR because FirstRate mixes date formats
314 +# ('2005-01-03 17:00:00' intraday vs '20100104' daily) — it is normalized to a
315 +# proper TIMESTAMP at conversion time (see _DT_PARSE).
316 +BAR_SCHEMAS = {
317 + 5: {"dt_raw": "VARCHAR", "open": "DOUBLE", "high": "DOUBLE",
318 + "low": "DOUBLE", "close": "DOUBLE"},
319 + 6: {"dt_raw": "VARCHAR", "open": "DOUBLE", "high": "DOUBLE",
320 + "low": "DOUBLE", "close": "DOUBLE", "volume": "DOUBLE"},
321 + 7: {"dt_raw": "VARCHAR", "open": "DOUBLE", "high": "DOUBLE",
322 + "low": "DOUBLE", "close": "DOUBLE", "volume": "DOUBLE",
323 + "open_interest": "DOUBLE"},
324 +}
325 +
326 +_DT_PARSE = ("COALESCE("
327 + "try_strptime(dt_raw, '%Y-%m-%d %H:%M:%S'), "
328 + "try_strptime(dt_raw, '%Y%m%d'), "
329 + "try_strptime(dt_raw, '%Y-%m-%d'), "
330 + "try_strptime(dt_raw, '%m/%d/%Y %H:%M:%S'), "
331 + "try_strptime(dt_raw, '%m/%d/%Y'))")
332 +
333 +# Options chain layout, per https://firstratedata.com/_readme/options.txt :
334 +# Trade Date, Strike, Expiry Date, Call/Put, Last Trade Price, Bid Price,
335 +# Ask Price, Bid IV, Ask IV, Open Interest, Volume, Delta, Gamma, Vega,
336 +# Theta, Rho
337 +OPTIONS_SCHEMA = {
338 + "trade_date": "DATE", "strike": "DOUBLE", "expiry": "DATE",
339 + "call_put": "VARCHAR", "last_price": "DOUBLE", "bid": "DOUBLE",
340 + "ask": "DOUBLE", "bid_iv": "DOUBLE", "ask_iv": "DOUBLE",
341 + "open_interest": "DOUBLE", "volume": "DOUBLE", "delta": "DOUBLE",
342 + "gamma": "DOUBLE", "vega": "DOUBLE", "theta": "DOUBLE", "rho": "DOUBLE",
343 +}
344 +
345 +
346 +def _sniff_column_count(csv_path: Path) -> int:
347 + with open(csv_path, "r", errors="replace") as f:
348 + for line in f:
349 + line = line.strip()
350 + if line:
351 + return line.count(",") + 1
352 + return 0
353 +
354 +
355 +def _ticker_from_filename(path: Path) -> str:
356 + """'AAPL_1min.txt' -> 'AAPL' ; 'ES_contin_adj.txt' -> 'ES'."""
357 + return path.stem.split("_")[0].upper()
358 +
359 +
360 +def csv_to_parquet(con: duckdb.DuckDBPyConnection, csv_path: Path,
361 + out_path: Path, asset_type: str) -> int:
362 + """Convert one extracted csv/txt file to zstd Parquet. Returns row count."""
363 + ncols = _sniff_column_count(csv_path)
364 + if ncols == 0:
365 + return 0
366 + out_path.parent.mkdir(parents=True, exist_ok=True)
367 + ticker = _ticker_from_filename(csv_path)
368 + src = str(csv_path).replace("'", "''")
369 + dst = str(out_path).replace("'", "''")
370 +
371 + # strict_mode=false: FirstRate files mix LF and CRLF line endings (daily
372 + # rows are appended with CRLF onto LF history), which the strict CSV
373 + # sniffer rejects outright.
374 + common = "strict_mode=false, ignore_errors=true"
375 + if asset_type == "options" and ncols == len(OPTIONS_SCHEMA):
376 + cols = json.dumps(OPTIONS_SCHEMA).replace('"', "'")
377 + select = (f"SELECT '{ticker}' AS ticker, * FROM read_csv('{src}', "
378 + f"header=false, columns={cols}, dateformat='%Y-%m-%d', "
379 + f"{common})")
380 + elif asset_type != "options" and (schema := BAR_SCHEMAS.get(ncols)):
381 + cols = json.dumps(schema).replace('"', "'")
382 + value_cols = ", ".join(c for c in schema if c != "dt_raw")
383 + select = (f"SELECT '{ticker}' AS ticker, {_DT_PARSE} AS datetime, "
384 + f"{value_cols} FROM read_csv('{src}', "
385 + f"header=false, columns={cols}, {common})")
386 + else:
387 + # Unknown layout (options files etc.): let DuckDB auto-detect types,
388 + # header presence and column names — still fully queryable.
389 + select = (f"SELECT '{ticker}' AS ticker, * FROM read_csv_auto('{src}', "
390 + f"{common})")
391 +
392 + con.execute(f"COPY ({select}) TO '{dst}' "
393 + f"(FORMAT PARQUET, COMPRESSION ZSTD, ROW_GROUP_SIZE 1000000)")
394 + rows = con.execute(f"SELECT count(*) FROM read_parquet('{dst}')").fetchone()[0]
395 + if rows == 0 and csv_path.stat().st_size > 0:
396 + log.warning("0 rows converted from non-empty file %s (ncols=%d)",
397 + csv_path.name, ncols)
398 + return rows
399 +
400 +
401 +def extract_and_convert(zip_path: Path, dest_dir: Path, asset_type: str) -> tuple[int, int]:
402 + """Extract a downloaded zip (recursively if it nests zips) and convert every
403 + csv/txt member to Parquet under dest_dir. Returns (files, rows)."""
404 + files = rows = 0
405 + con = duckdb.connect()
406 + con.execute("SET threads TO 4")
407 + try:
408 + with tempfile.TemporaryDirectory(dir=zip_path.parent) as tmp:
409 + tmp_dir = Path(tmp)
410 + _extract_recursive(zip_path, tmp_dir)
411 + members = sorted(p for p in tmp_dir.rglob("*")
412 + if p.is_file() and p.suffix.lower() in (".txt", ".csv"))
413 + for member in members:
414 + out = dest_dir / (member.stem + ".parquet")
415 + try:
416 + n = csv_to_parquet(con, member, out, asset_type)
417 + if n > 0:
418 + files += 1
419 + rows += n
420 + else:
421 + out.unlink(missing_ok=True)
422 + except duckdb.Error as e:
423 + log.error("Convert failed %s: %s", member.name, e)
424 + member.unlink() # free space as we go
425 + finally:
426 + con.close()
427 + return files, rows
428 +
429 +
430 +def _extract_recursive(zip_path: Path, dest: Path, depth: int = 0) -> None:
431 + if depth > 3:
432 + return
433 + with zipfile.ZipFile(zip_path) as zf:
434 + zf.extractall(dest)
435 + for nested in list(dest.rglob("*.zip")):
436 + sub = nested.with_suffix("")
437 + sub.mkdir(exist_ok=True)
438 + _extract_recursive(nested, sub, depth + 1)
439 + nested.unlink()
440 +
441 +
442 +# ------------------------------------------------------------------------------
443 +# Downloader
444 +# ------------------------------------------------------------------------------
445 +
446 +
447 +@dataclass
448 +class Runner:
449 + data_root: Path
450 + manifest: Manifest
451 + keep_zips: bool = False
452 + min_free_gb: int = MIN_FREE_GB
453 + session: requests.Session = field(default_factory=requests.Session)
454 +
455 + def _free_gb(self) -> float:
456 + return shutil.disk_usage(self.data_root).free / 1e9
457 +
458 + def _download(self, job: Job, dest: Path) -> Path | None:
459 + """Stream the response to dest (via .part). Returns path or None."""
460 + part = dest.with_suffix(dest.suffix + ".part")
461 + for attempt in range(1, MAX_RETRIES + 1):
462 + if self._free_gb() < self.min_free_gb:
463 + raise RuntimeError(
464 + f"Only {self._free_gb():.1f} GB free (< {self.min_free_gb} GB floor)")
465 + try:
466 + with self.session.get(job.url, stream=True,
467 + timeout=DOWNLOAD_TIMEOUT) as r:
468 + if r.status_code in (429, 500, 502, 503, 504):
469 + raise requests.HTTPError(f"HTTP {r.status_code}")
470 + if r.status_code == 404:
471 + log.warning("[%s] 404 — no data for this combination", job.key)
472 + return None
473 + r.raise_for_status()
474 + dest.parent.mkdir(parents=True, exist_ok=True)
475 + size = 0
476 + with open(part, "wb") as f:
477 + for chunk in r.iter_content(CHUNK_SIZE):
478 + f.write(chunk)
479 + size += len(chunk)
480 + part.replace(dest)
481 + log.info("[%s] downloaded %.1f MB", job.key, size / 1e6)
482 + return dest
483 + except (requests.RequestException, OSError) as e:
484 + part.unlink(missing_ok=True)
485 + if attempt == MAX_RETRIES:
486 + raise
487 + wait = RETRY_BACKOFF * attempt
488 + log.warning("[%s] attempt %d/%d failed (%s), retrying in %ds",
489 + job.key, attempt, MAX_RETRIES, e, wait)
490 + time.sleep(wait)
491 + return None
492 +
493 + def run_job(self, job: Job) -> None:
494 + if self.manifest.is_done(job.key):
495 + log.info("[%s] already done, skipping", job.key)
496 + return
497 + log.info("[%s] GET %s", job.key, job.display_url)
498 + t0 = time.time()
499 + try:
500 + if job.kind == "meta":
501 + self._run_meta(job)
502 + else:
503 + self._run_bars(job)
504 + except Exception as e:
505 + log.error("[%s] FAILED: %s", job.key, e)
506 + self.manifest.mark(job.key, "failed", error=str(e))
507 + return
508 + log.info("[%s] done in %.0fs", job.key, time.time() - t0)
509 +
510 + def _run_meta(self, job: Job) -> None:
511 + out_dir = self.data_root / job.dest_subdir
512 + out_dir.mkdir(parents=True, exist_ok=True)
513 + fname = "_".join(str(v) for v in job.params.values()) + ".csv"
514 + out = out_dir / fname
515 + r = self.session.get(job.url, timeout=600)
516 + r.raise_for_status()
517 + out.write_bytes(r.content)
518 + self.manifest.mark(job.key, "done", bytes=len(r.content), file=str(out))
519 +
520 + def _run_bars(self, job: Job) -> None:
521 + zip_dir = self.data_root / "zips"
522 + zip_dir.mkdir(parents=True, exist_ok=True)
523 + zip_path = zip_dir / (job.key.replace("|", "_") + ".zip")
524 +
525 + got = self._download(job, zip_path)
526 + if got is None:
527 + self.manifest.mark(job.key, "empty")
528 + return
529 + if not zipfile.is_zipfile(zip_path):
530 + head = zip_path.read_bytes()[:200]
531 + zip_path.unlink()
532 + raise RuntimeError(f"Response is not a zip (starts with {head[:60]!r})")
533 +
534 + asset_type = job.key.split("|")[0]
535 + dest_dir = self.data_root / job.dest_subdir
536 + files, rows = extract_and_convert(zip_path, dest_dir, asset_type)
537 + size = zip_path.stat().st_size
538 + if not self.keep_zips:
539 + zip_path.unlink()
540 + self.manifest.mark(job.key, "done", bytes=size, files=files, rows=rows)
541 + log.info("[%s] converted %d files / %s rows -> %s",
542 + job.key, files, f"{rows:,}", dest_dir)
543 +
544 +
545 +# ------------------------------------------------------------------------------
546 +# CLI
547 +# ------------------------------------------------------------------------------
548 +
549 +
550 +def setup_logging(data_root: Path) -> None:
551 + log_dir = data_root / "logs"
552 + log_dir.mkdir(parents=True, exist_ok=True)
553 + fmt = logging.Formatter("%(asctime)s %(levelname)-7s %(message)s",
554 + "%Y-%m-%d %H:%M:%S")
555 + for handler in (logging.StreamHandler(sys.stdout),
556 + logging.FileHandler(log_dir / "frd_downloader.log")):
557 + handler.setFormatter(fmt)
558 + log.addHandler(handler)
559 + log.setLevel(logging.INFO)
560 +
561 +
562 +def main() -> int:
563 + ap = argparse.ArgumentParser(
564 + description="FirstRate Data full-history downloader "
565 + f"(v{__version__} — {__author__} <{__contact__}>)")
566 + ap.add_argument("--data-root", type=Path,
567 + default=Path("/Volumes/ssd/firstratedata"),
568 + help="Root directory for all data (default: %(default)s)")
569 + ap.add_argument("--types", nargs="+",
570 + default=["options", "stock", "etf", "futures",
571 + "crypto", "index", "fx"],
572 + choices=["options", "stock", "etf", "futures",
573 + "crypto", "index", "fx"],
574 + help="Asset types to download (default: all)")
575 + ap.add_argument("--workers", type=int, default=2,
576 + help="Parallel downloads (default: %(default)s — be polite)")
577 + ap.add_argument("--keep-zips", action="store_true",
578 + help="Keep raw zip archives after conversion")
579 + ap.add_argument("--include-delisted", action="store_true",
580 + help="Also download the delisted-stocks archives")
581 + ap.add_argument("--min-free-gb", type=int, default=MIN_FREE_GB,
582 + help="Stop downloading below this free space (default: %(default)s)")
583 + ap.add_argument("--list-jobs", action="store_true",
584 + help="Print the job plan and exit")
585 + args = ap.parse_args()
586 +
587 + if not USERID:
588 + ap.error("FRD_USERID environment variable is not set "
589 + "(your FirstRate Data customer id)")
590 +
591 + jobs = build_jobs(args.types, args.include_delisted)
592 +
593 + if args.list_jobs:
594 + for j in jobs:
595 + print(f"{j.priority:3d} {j.key:45s} {j.display_url}")
596 + print(f"\nTotal: {len(jobs)} jobs")
597 + return 0
598 +
599 + args.data_root.mkdir(parents=True, exist_ok=True)
600 + setup_logging(args.data_root)
601 + log.info("FirstRate Data downloader v%s — %s <%s>",
602 + __version__, __author__, __contact__)
603 + log.info("Data root: %s | types: %s | %d jobs | %d workers",
604 + args.data_root, ",".join(args.types), len(jobs), args.workers)
605 +
606 + manifest = Manifest(args.data_root / "state" / "manifest.json")
607 + (args.data_root / "state").mkdir(parents=True, exist_ok=True)
608 + runner = Runner(args.data_root, manifest,
609 + keep_zips=args.keep_zips, min_free_gb=args.min_free_gb)
610 +
611 + pending = [j for j in jobs if not manifest.is_done(j.key)]
612 + log.info("%d jobs pending (%d already complete)",
613 + len(pending), len(jobs) - len(pending))
614 +
615 + with ThreadPoolExecutor(max_workers=args.workers) as pool:
616 + futures = {pool.submit(runner.run_job, j): j for j in pending}
617 + for fut in as_completed(futures):
618 + fut.result() # exceptions are handled inside run_job
619 +
620 + counts = manifest.summary()
621 + log.info("Run complete. Manifest: %s", counts)
622 + failed = counts.get("failed", 0)
623 + return 1 if failed else 0
624 +
625 +
626 +if __name__ == "__main__":
627 + sys.exit(main())
added hfmarketdata/api/main.py +476 −0
@@ -0,0 +1,476 @@
1 +#!/usr/bin/env python3
2 +# -*- coding: utf-8 -*-
3 +"""
4 +================================================================================
5 + HF Market Data — Open High-Frequency Market Data API
6 + https://www.hfmarketdata.io
7 +================================================================================
8 +
9 +Ultra-fast open REST API serving historical market data (stocks, ETFs,
10 +futures, crypto, indices, FX — 1-minute to daily bars — plus full end-of-day
11 +options chains with quotes, implied volatilities and Greeks).
12 +
13 +The data layer is a partitioned Parquet lake (zstd) queried in-place with
14 +DuckDB — no database server, predicate pushdown straight into the row groups.
15 +
16 +Layout expected under DATA_ROOT (built by frd_downloader.py):
17 + parquet/{type}/{timeframe}/{adjustment}/{TICKER}_*.parquet
18 + parquet/options/{year}_{quarter}/{TICKER}_*_option_chain.parquet
19 +
20 +Author : Simon-Pierre Boucher
21 +Contact : contact@spboucher.ai
22 +================================================================================
23 +"""
24 +
25 +from __future__ import annotations
26 +
27 +import os
28 +import threading
29 +import time
30 +from pathlib import Path
31 +
32 +import duckdb
33 +from fastapi import FastAPI, HTTPException, Query
34 +from fastapi.middleware.cors import CORSMiddleware
35 +from fastapi.responses import PlainTextResponse
36 +from fastapi.staticfiles import StaticFiles
37 +
38 +__author__ = "Simon-Pierre Boucher"
39 +__contact__ = "contact@spboucher.ai"
40 +__version__ = "1.0.0"
41 +
42 +DATA_ROOT = Path(os.environ.get("HFMD_DATA_ROOT", "/Volumes/ssd/firstratedata"))
43 +PARQUET = DATA_ROOT / "parquet"
44 +WEB_DIST = Path(os.environ.get("HFMD_WEB_DIST",
45 + Path(__file__).resolve().parent.parent / "web" / "dist"))
46 +
47 +BAR_TYPES = ["stock", "etf", "futures", "futures_contracts", "crypto", "index", "fx"]
48 +TIMEFRAMES = ["1min", "5min", "30min", "1hour", "1day"]
49 +DEFAULT_ADJUSTMENT = {
50 + "stock": "adj_splitdiv", "etf": "adj_splitdiv",
51 + "futures": "contin_UNadj", "futures_contracts": "archive",
52 + "crypto": "none", "index": "none", "fx": "none",
53 +}
54 +MAX_LIMIT_JSON = 50_000
55 +MAX_LIMIT_CSV = 2_000_000
56 +
57 +app = FastAPI(
58 + title="HF Market Data API",
59 + description=(
60 + "Open high-frequency market data API — intraday & daily bars for "
61 + "stocks, ETFs, futures, crypto, indices and FX, plus full historical "
62 + "options chains (quotes, implied volatility, Greeks) since 2010. "
63 + f"Built by {__author__} ({__contact__})."
64 + ),
65 + version=__version__,
66 + contact={"name": __author__, "email": __contact__,
67 + "url": "https://www.hfmarketdata.io"},
68 +)
69 +app.add_middleware(
70 + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
71 +)
72 +
73 +# ------------------------------------------------------------------------------
74 +# DuckDB — one connection per thread, read-only usage
75 +# ------------------------------------------------------------------------------
76 +
77 +_tls = threading.local()
78 +
79 +
80 +def db() -> duckdb.DuckDBPyConnection:
81 + if not hasattr(_tls, "con"):
82 + _tls.con = duckdb.connect()
83 + _tls.con.execute("SET threads TO 4")
84 + _tls.con.execute("SET enable_object_cache=true")
85 + return _tls.con
86 +
87 +
88 +# ------------------------------------------------------------------------------
89 +# File resolution with a small TTL cache (directory scans are the slow part)
90 +# ------------------------------------------------------------------------------
91 +
92 +_cache: dict[str, tuple[float, object]] = {}
93 +_cache_lock = threading.Lock()
94 +CACHE_TTL = 300 # seconds
95 +
96 +
97 +def cached(key: str, builder):
98 + now = time.time()
99 + with _cache_lock:
100 + hit = _cache.get(key)
101 + if hit and now - hit[0] < CACHE_TTL:
102 + return hit[1]
103 + value = builder()
104 + with _cache_lock:
105 + _cache[key] = (now, value)
106 + return value
107 +
108 +
109 +def bar_dir(asset: str, timeframe: str, adjustment: str) -> Path:
110 + return PARQUET / asset / timeframe / adjustment
111 +
112 +
113 +def list_adjustments(asset: str, timeframe: str) -> list[str]:
114 + base = PARQUET / asset / timeframe
115 + if not base.is_dir():
116 + return []
117 + return sorted(p.name for p in base.iterdir() if p.is_dir())
118 +
119 +
120 +def ticker_index(asset: str, timeframe: str, adjustment: str) -> dict[str, str]:
121 + """ticker -> parquet file path, cached."""
122 + d = bar_dir(asset, timeframe, adjustment)
123 +
124 + def build():
125 + idx: dict[str, str] = {}
126 + if d.is_dir():
127 + for p in d.iterdir():
128 + if p.suffix == ".parquet":
129 + idx[p.stem.split("_")[0].upper()] = str(p)
130 + return idx
131 +
132 + return cached(f"idx|{d}", build)
133 +
134 +
135 +def options_quarters() -> list[str]:
136 + def build():
137 + base = PARQUET / "options"
138 + if not base.is_dir():
139 + return []
140 + return sorted(p.name for p in base.iterdir() if p.is_dir())
141 + return cached("opt_quarters", build)
142 +
143 +
144 +def options_files(ticker: str) -> list[str]:
145 + """All quarterly chain files for a ticker, cheap direct-path checks."""
146 + t = ticker.upper()
147 +
148 + def build():
149 + out = []
150 + for q in options_quarters():
151 + d = PARQUET / "options" / q
152 + for p in d.glob(f"{t}_*.parquet"):
153 + if p.stem.split("_")[0].upper() == t:
154 + out.append(str(p))
155 + return out
156 +
157 + return cached(f"optfiles|{t}", build)
158 +
159 +
160 +def _validate(asset: str, timeframe: str, adjustment: str | None) -> str:
161 + if asset not in BAR_TYPES:
162 + raise HTTPException(404, f"Unknown asset type '{asset}'. "
163 + f"Available: {', '.join(BAR_TYPES)}")
164 + if timeframe not in TIMEFRAMES:
165 + raise HTTPException(400, f"Unknown timeframe '{timeframe}'. "
166 + f"Available: {', '.join(TIMEFRAMES)}")
167 + adj = adjustment or DEFAULT_ADJUSTMENT[asset]
168 + available = list_adjustments(asset, timeframe)
169 + if available and adj not in available:
170 + raise HTTPException(400, f"Adjustment '{adj}' not available for "
171 + f"{asset}/{timeframe}. Available: {', '.join(available)}")
172 + return adj
173 +
174 +
175 +def _rows_to_response(rel, limit: int, fmt: str, order_col: str):
176 + if fmt == "csv":
177 + limit = min(limit, MAX_LIMIT_CSV)
178 + rel = rel.limit(limit)
179 + df = rel.df()
180 + return PlainTextResponse(df.to_csv(index=False), media_type="text/csv")
181 + limit = min(limit, MAX_LIMIT_JSON)
182 + rel = rel.limit(limit)
183 + df = rel.df()
184 + for col in df.columns:
185 + if str(df[col].dtype).startswith("datetime"):
186 + df[col] = df[col].astype(str)
187 + return {"count": len(df), "data": df.to_dict(orient="records")}
188 +
189 +
190 +# ------------------------------------------------------------------------------
191 +# Meta endpoints
192 +# ------------------------------------------------------------------------------
193 +
194 +
195 +@app.get("/health", tags=["meta"])
196 +def health():
197 + return {"status": "ok", "service": "hfmarketdata-api", "version": __version__,
198 + "data_root_present": PARQUET.is_dir()}
199 +
200 +
201 +@app.get("/v1/status", tags=["meta"])
202 +def status():
203 + """Dataset inventory: every asset type, timeframe and adjustment with
204 + the number of instruments currently available."""
205 + def build():
206 + out = {}
207 + for asset in BAR_TYPES:
208 + base = PARQUET / asset
209 + if not base.is_dir():
210 + continue
211 + tfs = {}
212 + for tf in sorted(p.name for p in base.iterdir() if p.is_dir()):
213 + adjs = {}
214 + for adj in list_adjustments(asset, tf):
215 + n = sum(1 for p in (base / tf / adj).iterdir()
216 + if p.suffix == ".parquet")
217 + adjs[adj] = n
218 + tfs[tf] = adjs
219 + out[asset] = tfs
220 + quarters = options_quarters()
221 + if quarters:
222 + out["options"] = {
223 + "quarters": quarters,
224 + "files_latest_quarter":
225 + sum(1 for _ in (PARQUET / "options" / quarters[-1]).glob("*.parquet")),
226 + }
227 + return out
228 + return {"datasets": cached("status", build)}
229 +
230 +
231 +@app.get("/v1/{asset}/tickers", tags=["bars"])
232 +def tickers(asset: str,
233 + timeframe: str = Query("1day"),
234 + adjustment: str | None = Query(None),
235 + search: str | None = Query(None, description="Substring filter"),
236 + limit: int = Query(10_000, ge=1)):
237 + """List available tickers for an asset type / timeframe / adjustment.
238 + For `options`, this delegates to the options underlyings listing."""
239 + if asset == "options":
240 + return opt_tickers(quarter=None, search=search, limit=limit)
241 + adj = _validate(asset, timeframe, adjustment)
242 + idx = ticker_index(asset, timeframe, adj)
243 + names = sorted(idx)
244 + if search:
245 + s = search.upper()
246 + names = [t for t in names if s in t]
247 + return {"asset": asset, "timeframe": timeframe, "adjustment": adj,
248 + "count": len(names[:limit]), "tickers": names[:limit]}
249 +
250 +
251 +# ------------------------------------------------------------------------------
252 +# Bars
253 +# ------------------------------------------------------------------------------
254 +
255 +
256 +@app.get("/v1/bars/{asset}/{ticker}", tags=["bars"])
257 +def bars(asset: str, ticker: str,
258 + timeframe: str = Query("1day", description="1min|5min|30min|1hour|1day"),
259 + adjustment: str | None = Query(None, description="Default depends on asset type"),
260 + start: str | None = Query(None, description="ISO date/datetime lower bound"),
261 + end: str | None = Query(None, description="ISO date/datetime upper bound"),
262 + order: str = Query("asc", pattern="^(asc|desc)$"),
263 + limit: int = Query(5_000, ge=1),
264 + format: str = Query("json", pattern="^(json|csv)$")):
265 + """OHLCV bars for one instrument. Daily futures bars also carry open
266 + interest. `format=csv` allows bulk extraction (up to 2M rows)."""
267 + adj = _validate(asset, timeframe, adjustment)
268 + idx = ticker_index(asset, timeframe, adj)
269 + path = idx.get(ticker.upper())
270 + if not path:
271 + raise HTTPException(404, f"Ticker '{ticker.upper()}' not found in "
272 + f"{asset}/{timeframe}/{adj}")
273 + con = db()
274 + conds, params = [], []
275 + if start:
276 + conds.append("datetime >= ?"); params.append(start)
277 + if end:
278 + conds.append("datetime <= ?"); params.append(end)
279 + where = ("WHERE " + " AND ".join(conds)) if conds else ""
280 + rel = con.sql(
281 + f"SELECT * FROM read_parquet(?) {where} ORDER BY datetime {order.upper()}",
282 + params=[path, *params])
283 + return _rows_to_response(rel, limit, format, "datetime")
284 +
285 +
286 +@app.get("/v1/bars/{asset}", tags=["bars"])
287 +def bars_multi(asset: str,
288 + tickers: str = Query(..., description="Comma-separated list, e.g. AAPL,MSFT,TSLA (max 50)"),
289 + timeframe: str = Query("1day"),
290 + adjustment: str | None = Query(None),
291 + start: str | None = Query(None, description="ISO date/datetime lower bound"),
292 + end: str | None = Query(None, description="ISO date/datetime upper bound"),
293 + order: str = Query("asc", pattern="^(asc|desc)$"),
294 + limit: int = Query(5_000, ge=1, description="Max rows PER TICKER"),
295 + format: str = Query("json", pattern="^(json|csv)$")):
296 + """Bars for SEVERAL instruments in one call. Combine with `start`/`end`
297 + to slice any precise window — e.g. 4 hours of 1-minute bars across a
298 + whole watchlist. The row limit applies per ticker."""
299 + adj = _validate(asset, timeframe, adjustment)
300 + idx = ticker_index(asset, timeframe, adj)
301 + names = [t.strip().upper() for t in tickers.split(",") if t.strip()][:50]
302 + if not names:
303 + raise HTTPException(400, "No tickers given")
304 + missing = [t for t in names if t not in idx]
305 + if missing:
306 + raise HTTPException(404, f"Not found in {asset}/{timeframe}/{adj}: "
307 + f"{', '.join(missing)}")
308 + paths = [idx[t] for t in names]
309 + con = db()
310 + conds, params = [], []
311 + if start:
312 + conds.append("datetime >= ?"); params.append(start)
313 + if end:
314 + conds.append("datetime <= ?"); params.append(end)
315 + where = ("WHERE " + " AND ".join(conds)) if conds else ""
316 + per_ticker = min(limit, MAX_LIMIT_CSV if format == "csv" else MAX_LIMIT_JSON)
317 + rel = con.sql(
318 + f"SELECT * FROM read_parquet(?) {where} "
319 + f"QUALIFY row_number() OVER (PARTITION BY ticker ORDER BY datetime "
320 + f"{order.upper()}) <= ? ORDER BY ticker, datetime {order.upper()}",
321 + params=[paths, *params, per_ticker])
322 + return _rows_to_response(rel, per_ticker * len(names), format, "datetime")
323 +
324 +
325 +@app.get("/v1/snapshot/{asset}", tags=["bars"])
326 +def snapshot(asset: str,
327 + tickers: str = Query(..., description="Comma-separated list, e.g. AAPL,MSFT,TSLA (max 50)"),
328 + at: str = Query(..., description="Precise moment, e.g. 2024-06-03 10:35:00"),
329 + timeframe: str = Query("1min"),
330 + adjustment: str | None = Query(None),
331 + format: str = Query("json", pattern="^(json|csv)$")):
332 + """Cross-sectional snapshot: for each requested instrument, the last bar
333 + at or before the given moment — the state of a whole watchlist at one
334 + precise point in time."""
335 + adj = _validate(asset, timeframe, adjustment)
336 + idx = ticker_index(asset, timeframe, adj)
337 + names = [t.strip().upper() for t in tickers.split(",") if t.strip()][:50]
338 + if not names:
339 + raise HTTPException(400, "No tickers given")
340 + missing = [t for t in names if t not in idx]
341 + if missing:
342 + raise HTTPException(404, f"Not found in {asset}/{timeframe}/{adj}: "
343 + f"{', '.join(missing)}")
344 + paths = [idx[t] for t in names]
345 + con = db()
346 + rel = con.sql(
347 + "SELECT * FROM read_parquet(?) WHERE datetime <= ? "
348 + "QUALIFY row_number() OVER (PARTITION BY ticker ORDER BY datetime DESC) = 1 "
349 + "ORDER BY ticker",
350 + params=[paths, at])
351 + return _rows_to_response(rel, len(names), format, "datetime")
352 +
353 +
354 +# ------------------------------------------------------------------------------
355 +# Options
356 +# ------------------------------------------------------------------------------
357 +
358 +
359 +@app.get("/v1/options/quarters", tags=["options"])
360 +def opt_quarters():
361 + """Available options archive quarters (e.g. 2010_q1 … current)."""
362 + return {"quarters": options_quarters()}
363 +
364 +
365 +@app.get("/v1/options/tickers", tags=["options"])
366 +def opt_tickers(quarter: str | None = Query(None, description="e.g. 2024_q4; default latest"),
367 + search: str | None = None,
368 + limit: int = Query(10_000, ge=1)):
369 + """Underlyings available in a given options quarter."""
370 + quarters = options_quarters()
371 + if not quarters:
372 + raise HTTPException(503, "Options dataset not yet available")
373 + q = quarter or quarters[-1]
374 + if q not in quarters:
375 + raise HTTPException(404, f"Unknown quarter '{q}'")
376 +
377 + def build():
378 + return sorted({p.stem.split("_")[0].upper()
379 + for p in (PARQUET / "options" / q).glob("*.parquet")})
380 + names = cached(f"opt_tickers|{q}", build)
381 + if search:
382 + s = search.upper()
383 + names = [t for t in names if s in t]
384 + return {"quarter": q, "count": len(names[:limit]), "tickers": names[:limit]}
385 +
386 +
387 +@app.get("/v1/options/chain/{ticker}", tags=["options"])
388 +def opt_chain(ticker: str,
389 + trade_date: str | None = Query(None, description="yyyy-mm-dd; default latest available"),
390 + expiry: str | None = Query(None, description="Filter on expiry date"),
391 + call_put: str | None = Query(None, pattern="^(c|p)$"),
392 + strike_min: float | None = None,
393 + strike_max: float | None = None,
394 + min_volume: float | None = None,
395 + limit: int = Query(20_000, ge=1),
396 + format: str = Query("json", pattern="^(json|csv)$")):
397 + """Full end-of-day option chain for an underlying: quotes, bid/ask implied
398 + volatility, open interest, volume and Greeks (delta, gamma, vega, theta, rho)."""
399 + files = options_files(ticker)
400 + if not files:
401 + raise HTTPException(404, f"No options data for '{ticker.upper()}'")
402 + con = db()
403 + if trade_date is None:
404 + trade_date = con.execute(
405 + "SELECT max(trade_date) FROM read_parquet(?)", [files[-1]]
406 + ).fetchone()[0]
407 + files_q = [files[-1]]
408 + else:
409 + files_q = files
410 + conds, params = ["trade_date = ?"], [str(trade_date)]
411 + if expiry:
412 + conds.append("expiry = ?"); params.append(expiry)
413 + if call_put:
414 + conds.append("call_put = ?"); params.append(call_put)
415 + if strike_min is not None:
416 + conds.append("strike >= ?"); params.append(strike_min)
417 + if strike_max is not None:
418 + conds.append("strike <= ?"); params.append(strike_max)
419 + if min_volume is not None:
420 + conds.append("volume >= ?"); params.append(min_volume)
421 + rel = con.sql(
422 + f"SELECT * FROM read_parquet(?) WHERE {' AND '.join(conds)} "
423 + f"ORDER BY expiry, strike, call_put",
424 + params=[files_q, *params])
425 + return _rows_to_response(rel, limit, format, "expiry")
426 +
427 +
428 +@app.get("/v1/options/expirations/{ticker}", tags=["options"])
429 +def opt_expirations(ticker: str,
430 + trade_date: str | None = Query(None, description="yyyy-mm-dd")):
431 + """Available expiry dates (optionally as of one trade date)."""
432 + files = options_files(ticker)
433 + if not files:
434 + raise HTTPException(404, f"No options data for '{ticker.upper()}'")
435 + con = db()
436 + if trade_date:
437 + rows = con.execute(
438 + "SELECT DISTINCT expiry FROM read_parquet(?) WHERE trade_date = ? ORDER BY expiry",
439 + [files, trade_date]).fetchall()
440 + else:
441 + rows = con.execute(
442 + "SELECT DISTINCT expiry FROM read_parquet(?) ORDER BY expiry",
443 + [files[-1]]).fetchall()
444 + return {"ticker": ticker.upper(), "expirations": [str(r[0]) for r in rows]}
445 +
446 +
447 +@app.get("/v1/options/history/{ticker}", tags=["options"])
448 +def opt_history(ticker: str,
449 + strike: float = Query(...),
450 + expiry: str = Query(..., description="yyyy-mm-dd"),
451 + call_put: str = Query(..., pattern="^(c|p)$"),
452 + limit: int = Query(5_000, ge=1),
453 + format: str = Query("json", pattern="^(json|csv)$")):
454 + """Daily time series for one specific contract across its whole life."""
455 + files = options_files(ticker)
456 + if not files:
457 + raise HTTPException(404, f"No options data for '{ticker.upper()}'")
458 + con = db()
459 + rel = con.sql(
460 + "SELECT * FROM read_parquet(?) WHERE strike = ? AND expiry = ? AND call_put = ? "
461 + "ORDER BY trade_date",
462 + params=[files, strike, expiry, call_put])
463 + return _rows_to_response(rel, limit, format, "trade_date")
464 +
465 +
466 +# ------------------------------------------------------------------------------
467 +# Static React platform (mounted last so it doesn't shadow the API)
468 +# ------------------------------------------------------------------------------
469 +
470 +if WEB_DIST.is_dir():
471 + app.mount("/", StaticFiles(directory=str(WEB_DIST), html=True), name="web")
472 +
473 +
474 +if __name__ == "__main__":
475 + import uvicorn
476 + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("HFMD_PORT", 8090)))
added hfmarketdata/requirements.txt +4 −0
@@ -0,0 +1,4 @@
1 +fastapi>=0.115
2 +uvicorn>=0.30
3 +duckdb>=1.0
4 +pandas>=2.0
added hfmarketdata/web/index.html +16 −0
@@ -0,0 +1,16 @@
1 +<!doctype html>
2 +<html lang="en">
3 + <head>
4 + <meta charset="UTF-8" />
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 + <meta name="description" content="HF Market Data — free open API for high-frequency historical market data: stocks, ETFs, futures, crypto, indices, FX (1-minute to daily) and full options chains with Greeks since 2010." />
7 + <title>HF Market Data — Open High-Frequency Market Data API</title>
8 + <link rel="preconnect" href="https://fonts.googleapis.com" />
9 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10 + <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400;1,9..144,500&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
11 + </head>
12 + <body>
13 + <div id="root"></div>
14 + <script type="module" src="/src/main.jsx"></script>
15 + </body>
16 +</html>
added hfmarketdata/web/package-lock.json +1857 −0
Large diff (1,857 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/package.json +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/App.jsx +403 −0
Large diff (403 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/main.jsx +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/spec.js +329 −0
Large diff (329 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/src/styles.css +457 −0
Large diff (457 lines) is not loaded by default to keep the page fast.Load diff
added hfmarketdata/web/vite.config.js +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added requirements.txt +2 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme