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)
JavaScript 53.7%
Python 38.3%
CSS 4.6%
TypeScript 3.1%
1"""Chantier api — strict v1 validation (400 instead of 500), partial multi-ticker results, tier-bounded limits,2HTTP hygiene (gzip, X-Request-ID, security headers, caching), /health, Swagger moved to /swagger."""3from __future__ import annotations45import json6import logging78import pytest910# ----------------------------------------------------------------------------------------------------- validation111213@pytest.mark.parametrize("url", [14 "/v1/bars/stock/AAPL?timeframe=1day&start=bad",15 "/v1/bars/stock/AAPL?timeframe=1day&end=2024-13-45",16 "/v1/bars/stock/AAPL?timeframe=1day&start=2024-01-01T25:00:00",17 "/v1/bars/stock?tickers=AAPL,MSFT&timeframe=1day&start=nope",18 "/v1/snapshot/stock?tickers=AAPL&at=yesterday",19 "/v1/options/chain/AAPL?trade_date=2025-04-0x",20 "/v1/options/chain/AAPL?expiry=soon",21 "/v1/options/expirations/AAPL?trade_date=x",22 "/v1/options/history/AAPL?strike=200&expiry=x&call_put=c",23])24def test_invalid_dates_are_400(client, url):25 r = client.get(url)26 assert r.status_code == 400, r.text27 body = r.json()28 assert body["error"]["code"] == "INVALID_PARAMETER"29 assert body["detail"] # legacy field30 assert "Traceback" not in r.text313233def test_end_before_start_is_400(client):34 r = client.get("/v1/bars/stock/AAPL?timeframe=1day&start=2024-06-10&end=2024-06-01")35 assert r.status_code == 40036 assert r.json()["error"]["code"] == "INVALID_PARAMETER"37 assert "start" in r.json()["error"]["details"]383940@pytest.mark.parametrize("start,end", [41 ("2024-06-03", "2024-06-04"),42 ("2024-06-03 09:30:00", "2024-06-03 09:34:59"),43 ("2024-06-03T09:30:00", "2024-06-03T09:34:59Z"),44 ("2024-06-03T09:30:00+00:00", None),45 ("2024-06-03T05:30:00-04:00", "2024-06-04"),46])47def test_accepted_date_forms(client, start, end):48 from urllib.parse import quote49 q = f"start={quote(start)}" + (f"&end={quote(end)}" if end else "")50 r = client.get(f"/v1/bars/stock/AAPL?timeframe=1day&{q}")51 assert r.status_code == 200, r.text525354def test_window_is_honoured_and_typed(client):55 r = client.get("/v1/bars/stock/AAPL?timeframe=1min&start=2025-06-30 09:30:00&end=2025-06-30 09:34:59&adjustment=UNADJUSTED")56 assert r.status_code == 20057 rows = r.json()["data"]58 assert len(rows) == 559 assert rows[0]["datetime"].startswith("2025-06-30 09:30") and rows[-1]["datetime"].startswith("2025-06-30 09:34")606162def test_too_many_tickers_is_400(client):63 names = ",".join(f"T{i}" for i in range(51))64 r = client.get(f"/v1/bars/stock?tickers={names}&timeframe=1day")65 assert r.status_code == 40066 assert r.json()["error"]["code"] == "TOO_MANY_TICKERS"67 r = client.get(f"/v1/snapshot/stock?tickers={names}&at=2025-06-30 10:00:00")68 assert r.status_code == 400 and r.json()["error"]["code"] == "TOO_MANY_TICKERS"697071def test_fifty_tickers_ok_dedup(client):72 names = ",".join(["AAPL", "aapl", "MSFT"] + [f"T{i}" for i in range(47)]) # 49 distinct73 r = client.get(f"/v1/bars/stock?tickers={names}&timeframe=1day&limit=1")74 assert r.status_code == 20075 assert sorted({row["ticker"] for row in r.json()["data"]}) == ["AAPL", "MSFT"]767778def test_empty_tickers_is_400(client):79 r = client.get("/v1/bars/stock?tickers=,,&timeframe=1day")80 assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER"818283# -------------------------------------------------------------------------------------------- partial results848586def test_partially_missing_tickers_200_with_header(client):87 r = client.get("/v1/bars/stock?tickers=AAPL,NOPE,MSFT,ZZZ&timeframe=1day&limit=2")88 assert r.status_code == 200, r.text89 assert r.headers["X-Missing-Tickers"] == "NOPE,ZZZ"90 body = r.json()91 assert body["count"] == 4 and {row["ticker"] for row in body["data"]} == {"AAPL", "MSFT"}92 assert r.headers["X-Row-Count"] == "4"939495def test_all_missing_tickers_404(client):96 r = client.get("/v1/bars/stock?tickers=NOPE,ZZZ&timeframe=1day")97 assert r.status_code == 40498 assert r.json()["error"]["code"] == "TICKER_NOT_FOUND"99 assert r.json()["error"]["details"]["missing"] == ["NOPE", "ZZZ"]100101102def test_no_missing_header_when_all_found(client):103 r = client.get("/v1/bars/stock?tickers=AAPL,MSFT&timeframe=1day&limit=1")104 assert r.status_code == 200 and "X-Missing-Tickers" not in r.headers105106107def test_snapshot_partial(client):108 r = client.get("/v1/snapshot/stock?tickers=AAPL,NOPE&at=2025-06-30 10:00:00&adjustment=UNADJUSTED")109 assert r.status_code == 200110 assert r.headers["X-Missing-Tickers"] == "NOPE"111 body = r.json()112 assert body["count"] == 1 and body["data"][0]["ticker"] == "AAPL"113 assert body["data"][0]["datetime"].startswith("2025-06-30 10:00")114115116def test_multi_per_ticker_limit_and_order(client):117 r = client.get("/v1/bars/stock?tickers=AAPL,MSFT&timeframe=1day&limit=3&order=desc")118 assert r.status_code == 200119 rows = r.json()["data"]120 assert len(rows) == 6121 aapl = [x["datetime"] for x in rows if x["ticker"] == "AAPL"]122 assert aapl == sorted(aapl, reverse=True) and aapl[0].startswith("2025-06-30")123124125# ------------------------------------------------------------------------------------------------- limits126127128def test_limit_bounded_by_tier_max_rows(client):129 """Keyless tier: 5 000 rows per request. 6 tickers × 1 950 one-minute bars would be 11 700 rows."""130 r = client.get("/v1/bars/stock?tickers=AAPL,MSFT,SMCP,SHAK,GOOG,GOOGL&timeframe=1min&limit=5000&adjustment=UNADJUSTED")131 assert r.status_code == 200132 assert r.headers["X-Row-Count"] == "5000" and r.json()["count"] == 5000133134135def test_single_limit_bounded_by_tier_max_rows(client_hu, client):136 from core.params import bound_limit137138 class _Req:139 class state:140 max_rows = 100141 assert bound_limit(5000, 50_000, _Req()) == 100142 assert bound_limit(50, 50_000, _Req()) == 50143144 class _NoTier:145 class state:146 pass147 assert bound_limit(5_000_000, 50_000, _NoTier()) == 50_000148 assert bound_limit(10, 50_000, None) == 10149150151# --------------------------------------------------------------------------------------------- HTTP hygiene152153154def test_gzip_when_accepted(client):155 r = client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=500", headers={"Accept-Encoding": "gzip"})156 assert r.status_code == 200157 assert r.headers.get("Content-Encoding") == "gzip"158 assert r.json()["count"] == 500 # httpx decodes transparently159160161def test_no_gzip_for_small_bodies(client):162 r = client.get("/health", headers={"Accept-Encoding": "gzip"})163 assert r.status_code in (200, 503)164 # /health is above 1 KiB only with many checks; the point is that gzip is negotiated, not forced165 r = client.get("/v1/options/quarters", headers={"Accept-Encoding": "gzip"})166 assert "Content-Encoding" not in r.headers167168169def test_request_id_generated_and_echoed(client):170 r = client.get("/v1/options/quarters")171 rid = r.headers["X-Request-ID"]172 assert 8 <= len(rid) <= 64173 r2 = client.get("/v1/options/quarters", headers={"X-Request-ID": "trace-abc.123"})174 assert r2.headers["X-Request-ID"] == "trace-abc.123"175 r3 = client.get("/v1/options/quarters", headers={"X-Request-ID": "bad id with spaces"})176 assert r3.headers["X-Request-ID"] != "bad id with spaces"177178179def test_request_id_on_errors_too(client):180 r = client.get("/v1/bars/stock/NOPE?timeframe=1day")181 assert r.status_code == 404 and r.headers.get("X-Request-ID")182183184def test_access_log_line_is_json(client, caplog):185 with caplog.at_level(logging.INFO, logger="hfmarketdata.access"):186 client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=7", headers={"X-Request-ID": "log-test-1"})187 lines = [rec.getMessage() for rec in caplog.records if rec.name == "hfmarketdata.access"]188 assert lines, "no access log line"189 rec = json.loads(lines[-1])190 assert rec["request_id"] == "log-test-1" and rec["method"] == "GET" and rec["path"] == "/v1/bars/stock/AAPL"191 assert rec["status"] == 200 and rec["rows"] == 7 and rec["bytes"] > 0 and rec["duration_ms"] >= 0192 assert "principal" in rec and rec["query"].startswith("timeframe=1day")193194195def test_security_headers_on_json(client):196 r = client.get("/v1/options/quarters")197 assert r.headers["Strict-Transport-Security"] == "max-age=31536000; includeSubDomains"198 assert r.headers["X-Content-Type-Options"] == "nosniff"199 assert r.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"200 assert r.headers["X-Frame-Options"] == "DENY"201 assert "Content-Security-Policy" not in r.headers202203204def test_swagger_and_redoc_moved(client):205 r = client.get("/swagger")206 assert r.status_code == 200 and "swagger-ui" in r.text207 assert "Content-Security-Policy" in r.headers and "cdn.jsdelivr.net" in r.headers["Content-Security-Policy"]208 assert "X-Frame-Options" not in r.headers209 r = client.get("/redoc")210 assert r.status_code == 200 and "redoc" in r.text.lower()211212213def test_docs_is_not_swagger(client):214 """Without a built front-end the shell is absent (404 JSON) — but never Swagger UI."""215 r = client.get("/docs")216 assert "swagger-ui" not in r.text217 assert r.status_code == 404218219220def test_openapi_etag_and_cache(client):221 r = client.get("/openapi.json")222 assert r.status_code == 200223 etag = r.headers["ETag"]224 assert etag.startswith('"') and r.headers["Cache-Control"].startswith("public")225 spec = r.json()226 assert spec["openapi"] == "3.1.0" and "/v1/bars/{asset}" in spec["paths"]227 r2 = client.get("/openapi.json", headers={"If-None-Match": etag})228 assert r2.status_code == 304 and r2.headers["ETag"] == etag229230231def test_openapi_documents_new_errors_and_headers(client):232 spec = client.get("/openapi.json").json()233 op = spec["paths"]["/v1/bars/{asset}"]["get"]234 assert "X-Missing-Tickers" in op["responses"]["200"]["headers"]235 assert "X-Request-ID" in op["responses"]["200"]["headers"]236 assert "TOO_MANY_TICKERS" in op["responses"]["400"]["description"]237 assert "TOO_MANY_TICKERS" in spec["components"]["schemas"]["Error"]["properties"]["error"]["properties"]["code"]["enum"]238239240def test_history_cache_control(client):241 r = client.get("/v1/bars/stock/AAPL?timeframe=1day&start=2024-01-01&end=2024-01-31")242 assert r.status_code == 200 and r.headers["Cache-Control"] == "public, max-age=86400"243 r = client.get("/v1/bars/stock/AAPL?timeframe=1day&start=2024-01-01") # open-ended → not cacheable244 assert "Cache-Control" not in r.headers245 r = client.get("/v1/bars/stock/AAPL?timeframe=1day&start=2024-01-01&end=2999-01-01")246 assert "Cache-Control" not in r.headers247 r = client.get("/v1/bars/stock?tickers=AAPL,MSFT&timeframe=1day&start=2024-01-01&end=2024-01-31")248 assert r.headers["Cache-Control"] == "public, max-age=86400"249250251def test_login_redirects_to_signin(client):252 r = client.get("/login", follow_redirects=False)253 assert r.status_code == 301 and r.headers["Location"] == "/signin"254255256# ------------------------------------------------------------------------------------------------- /health257258259def test_health_extended(client):260 r = client.get("/health")261 assert r.status_code == 200, r.text262 body = r.json()263 assert body["status"] == "ok" and body["data_root_present"] is True264 checks = body["checks"]265 assert checks["parquet"]["ok"] and checks["sqlite"]["ok"] and checks["redis"]["ok"] and checks["duckdb"]["ok"]266 assert checks["redis"].get("backend") == "fakeredis"267 assert checks["duckdb"]["rows"] > 0 and checks["duckdb"]["file"].endswith(".parquet")268 assert "X-RateLimit-Limit-Requests" not in r.headers # exempt from quotas269 assert r.headers["Cache-Control"] == "no-store"270271272def test_health_degraded_is_503(client, monkeypatch):273 from core import health274 monkeypatch.setattr(health, "_check_sqlite", lambda: {"ok": False, "error": "boom"})275 r = client.get("/health")276 assert r.status_code == 503 and r.json()["status"] == "degraded"277 assert r.json()["checks"]["sqlite"]["error"] == "boom"278279280def test_status_is_quota_exempt(client):281 r = client.get("/v1/status")282 assert r.status_code == 200283 # the request is counted, the rows are not284 assert r.headers.get("X-RateLimit-Remaining-Rows") == r.headers.get("X-RateLimit-Limit-Rows")285286287# --------------------------------------------------------------------------------------- v1 errors unchanged288289290def test_unknown_asset_404_asset_code(client):291 r = client.get("/v1/bars/bond/XYZ")292 assert r.status_code == 404 and r.json()["error"]["code"] == "ASSET_NOT_FOUND"293294295def test_unknown_timeframe_400(client):296 r = client.get("/v1/bars/stock/AAPL?timeframe=2min")297 assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER"298299300def test_duckdb_error_never_leaks_traceback(client, monkeypatch):301 import duckdb302 import main303304 class _Boom:305 def execute(self, *a, **k):306 raise duckdb.ConversionException("Conversion Error: invalid timestamp field format")307308 monkeypatch.setattr(main, "con", lambda: _Boom())309 r = client.get("/v1/bars/stock/AAPL?timeframe=1day")310 assert r.status_code == 400311 assert r.json()["error"]["code"] == "INVALID_PARAMETER" and "Traceback" not in r.text312313 class _IO:314 def execute(self, *a, **k):315 raise duckdb.IOException("IO Error: No such file")316317 monkeypatch.setattr(main, "con", lambda: _IO())318 r = client.get("/v1/bars/stock/AAPL?timeframe=1day")319 assert r.status_code == 503 and r.json()["error"]["code"] == "SERVICE_UNAVAILABLE"320