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%
13.2 KB · 320 lines python
Raw Blame History
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