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.1 KB · 276 lines python
Raw Blame History
1"""WebSocket /v1/stream: auth, subscribe, delivery of published events, resume, heartbeat, connection limit."""2from __future__ import annotations34import pytest5from starlette.websockets import WebSocketDisconnect6789@pytest.fixture10def keys(make_user):11    """Two real high_usage API keys (the rate-limit middleware validates keys before the stream handler)."""12    _, k1, _, _ = make_user(tier="high_usage")13    _, k2, _, _ = make_user(tier="high_usage")14    return k1, k2151617@pytest.fixture(autouse=True)18def _reset(app):19    from stream import broker20    broker.reset_for_tests()21    yield222324def test_keyless_is_refused_with_json_reason(client):25    with client.websocket_connect("/v1/stream") as ws:26        msg = ws.receive_json()27        assert msg["type"] == "error" and msg["code"] == "AUTH_REQUIRED" and msg["docs"].endswith("#auth_required")28        with pytest.raises(WebSocketDisconnect) as e:29            ws.receive_json()30        assert e.value.code == 4001313233def test_bad_key_is_refused(client):34    try:35        with client.websocket_connect("/v1/stream", headers={"Authorization": "Bearer not-a-key"}) as ws:36            msg = ws.receive_json()37            assert msg["code"] in ("AUTH_REQUIRED", "INVALID_API_KEY")38    except WebSocketDisconnect as e:   # refused by the rate-limit middleware before the handler39        assert e.code in (4401, 4001, 1008)404142def _key_id(raw: str) -> int:43    from sqlalchemy import select4445    from accounts.models import ApiKey46    from accounts.security import hash_key47    from core.db import session48    with session() as s:49        return int(s.scalar(select(ApiKey.id).where(ApiKey.key_hash == hash_key(raw))))505152def test_principal_is_the_key_id_not_a_hash(keys):53    """The stream must bill the same `key:<id>` principal as the HTTP limiter (a hashed pseudo-principal fed an54    orphan counter and resolved to the free tier)."""55    import hashlib5657    from core.config import settings58    from stream.auth import authenticate, principal_from_scope59    kid = _key_id(keys[0])60    assert authenticate(keys[0]) == f"key:{kid}"61    fake = "key:" + hashlib.sha256((settings.key_hash_salt + keys[0]).encode()).hexdigest()[:16]62    assert authenticate(keys[0]) != fake63    # well-formed but unknown keys are refused, whatever their shape64    assert authenticate("hfmd_live_" + "a" * 32) is None and authenticate("not-a-key") is None and authenticate(None) is None65    assert principal_from_scope({"state": {"principal": "key:7", "principal_kind": "key", "tier": "free"}}) == ("key:7", "key", "free")66    assert principal_from_scope({}) == (None, None, None)676869def test_well_formed_unknown_key_is_refused(client):70    with pytest.raises(WebSocketDisconnect) as e:71        with client.websocket_connect("/v1/stream?api_key=hfmd_live_" + "Z" * 32) as ws:72            ws.receive_json()73    assert e.value.code in (4401, 4001)747576def test_handler_refuses_without_middleware_context(keys):77    """`_resolve_principal`: middleware state wins; no state while the limiter is enabled → refuse, never guess."""78    import types7980    from core.config import settings81    from stream.routes import _resolve_principal82    ws = types.SimpleNamespace(scope={"state": {"principal": "key:42", "principal_kind": "key", "tier": "high_usage"}},83                               headers={"authorization": f"Bearer {keys[0]}"})84    assert _resolve_principal(ws, None) == ("key:42", "high_usage")85    ws.scope = {"state": {"principal": "ip:abc", "principal_kind": "keyless", "tier": "keyless"}}86    assert _resolve_principal(ws, keys[0]) == (None, "keyless")87    ws.scope = {}88    expected = (None, None) if settings.ratelimit_enabled else (f"key:{_key_id(keys[0])}", None)89    assert _resolve_principal(ws, keys[0]) == expected909192def test_subscribe_receive_and_accounting(keys, client, fundamentals_data):93    from stream import accounting, broker94    from stream.auth import authenticate95    principal = authenticate(keys[0])96    assert principal == f"key:{_key_id(keys[0])}"97    before = accounting.rows_charged(principal)98    with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws:99        hello = ws.receive_json()100        assert hello["type"] == "hello" and hello["heartbeat_seconds"] == 20101        ws.send_json({"action": "subscribe", "channel": "filings", "tickers": ["AAPL"], "forms": ["10-Q", "10-K"]})102        sub = ws.receive_json()103        assert sub["type"] == "subscribed" and sub["tickers"] == ["AAPL"] and sub["forms"] == ["10-K", "10-Q"]104        # events published by the ingestion (Redis) — one matching, two filtered out105        broker.publish({"type": "filing", "ticker": "MSFT", "form": "10-Q", "accn": "x1"})106        broker.publish({"type": "filing", "ticker": "AAPL", "form": "8-K", "accn": "x2"})107        seq = broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-Q", "accn": "x3", "filed_date": "2024-05-03",108                              "summary": {"revenue": 90_753_000_000}})109        ev = ws.receive_json()110        assert ev["type"] == "filing" and ev["accn"] == "x3" and ev["seq"] == seq and ev["summary"]["revenue"] == 90_753_000_000111        ws.send_json({"action": "ping"})112        pong = ws.receive_json()113        assert pong["type"] == "pong" and pong["seq"] == seq114    assert accounting.rows_charged(principal) == before + 1115116117def test_real_filing_event_from_ingest(keys, client, fundamentals_data):118    from datetime import date119120    from fundamentals import ingest121    with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws:122        ws.receive_json()123        ws.send_json({"action": "subscribe", "channel": "filings", "tickers": "all"})124        ws.receive_json()125        ev = ingest.publish_filing_event(320193, "AAPL", "10-Q", date(2024, 5, 3), date(2024, 3, 30), "0000320193-24-000069",126                                         "aapl-20240330.htm")127        assert ev is not None128        got = ws.receive_json()129        assert got["ticker"] == "AAPL" and got["form"] == "10-Q" and got["filed_date"] == "2024-05-03"130        assert got["url"].endswith("/000032019324000069/aapl-20240330.htm")131        s = got["summary"]132        assert s["revenue"] == 90_753_000_000 and s["net_income"] == 23_636_000_000 and s["eps_diluted"] == 1.53133        assert s["total_assets"] == 337_411_000_000 and s["operating_cash_flow"] == 22_690_000_000134        assert s["yoy"]["revenue"] == pytest.approx(90_753 / 94_836 - 1, rel=1e-4)135136137def test_resume_token_replays_buffer(keys, client):138    from stream import broker139    s1 = broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-K", "accn": "r1"})140    s2 = broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-K", "accn": "r2"})141    s3 = broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-K", "accn": "r3"})142    with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws:143        assert ws.receive_json()["seq"] == s3144        ws.send_json({"action": "subscribe", "channel": "filings", "tickers": ["AAPL"], "resume_token": s1})145        assert ws.receive_json()["resume_from"] == s1146        assert [ws.receive_json()["accn"] for _ in range(2)] == ["r2", "r3"]147        assert s2 < s3148    with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws:   # without resume: only new events149        ws.receive_json()150        ws.send_json({"action": "subscribe"})151        ws.receive_json()152        broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-K", "accn": "r4"})153        assert ws.receive_json()["accn"] == "r4"154155156def test_protocol_errors(keys, client):157    with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws:158        ws.receive_json()159        ws.send_text("not json")160        assert ws.receive_json()["code"] == "VALIDATION_ERROR"161        ws.send_json({"action": "subscribe", "channel": "trades"})162        assert ws.receive_json()["code"] == "NOT_FOUND"163        ws.send_json({"action": "subscribe", "tickers": []})164        assert ws.receive_json()["code"] == "VALIDATION_ERROR"165        ws.send_json({"action": "dance"})166        assert ws.receive_json()["code"] == "VALIDATION_ERROR"167        ws.send_json({"action": "subscribe", "resume_token": "abc"})168        assert ws.receive_json()["code"] == "VALIDATION_ERROR"169170171def test_heartbeat(keys, client, monkeypatch):172    from stream import routes173    monkeypatch.setattr(routes, "HEARTBEAT_SECONDS", 0.3)174    with client.websocket_connect(f"/v1/stream?api_key={keys[1]}") as ws:175        ws.receive_json()176        beat = ws.receive_json()177        assert beat["type"] == "heartbeat" and "ts" in beat and "seq" in beat178179180def test_connection_limit_per_key(keys, client):181    from contextlib import ExitStack182183    from stream import routes184    with ExitStack() as stack:185        for _ in range(routes.MAX_CONNECTIONS_PER_KEY):186            ws = stack.enter_context(client.websocket_connect(f"/v1/stream?api_key={keys[0]}"))187            assert ws.receive_json()["type"] == "hello"188        extra = stack.enter_context(client.websocket_connect(f"/v1/stream?api_key={keys[0]}"))189        msg = extra.receive_json()190        assert msg["type"] == "error" and msg["code"] == "STREAM_CONNECTION_LIMIT" and msg["limit"] == 5191        with pytest.raises(WebSocketDisconnect) as e:192            extra.receive_json()193        assert e.value.code == 4029194    # slots are released on disconnect195    with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws:196        assert ws.receive_json()["type"] == "hello"197198199def test_phantom_connections_are_pruned(keys, client):200    """A worker killed mid-stream never releases its slot: members whose heartbeat is older than 90 s must not201    count (the old INCR/DECR counter stayed inflated for an hour and produced unjustified 4029s)."""202    import time203204    from stream import broker, routes205    principal = f"key:{_key_id(keys[1])}"206    r = broker.get_redis()207    key = routes.conn_key(principal)208    r.delete(key)209    stale = time.time() - routes.CONN_STALE_SECONDS - 5210    r.zadd(key, {f"dead-{i}": stale for i in range(routes.MAX_CONNECTIONS_PER_KEY)})       # 5 phantom slots211    r.zadd(key, {"alive": time.time()})                                                     # 1 real one elsewhere212    with client.websocket_connect(f"/v1/stream?api_key={keys[1]}") as ws:213        assert ws.receive_json()["type"] == "hello"                                         # phantoms pruned214        members = r.zrange(key, 0, -1)215        assert len(members) == 2 and "alive" in members and not any(m.startswith("dead-") for m in members)216    assert r.zrange(key, 0, -1) == ["alive"]                                                # own slot released217    r.delete(key)218219220def test_heartbeat_refreshes_connection_score(keys, client, monkeypatch):221    import time222223    from stream import broker, routes224    monkeypatch.setattr(routes, "HEARTBEAT_SECONDS", 0.2)225    monkeypatch.setattr(routes, "CONN_HEARTBEAT_SECONDS", 0.0)226    principal = f"key:{_key_id(keys[1])}"227    r = broker.get_redis()228    with client.websocket_connect(f"/v1/stream?api_key={keys[1]}") as ws:229        ws.receive_json()230        conn_id = r.zrange(routes.conn_key(principal), 0, -1)[0]231        s0 = r.zscore(routes.conn_key(principal), conn_id)232        assert ws.receive_json()["type"] == "heartbeat"233        assert ws.receive_json()["type"] == "heartbeat"234        assert r.zscore(routes.conn_key(principal), conn_id) >= s0 and time.time() - s0 < 5235236237def test_delivery_is_blocking_read_not_polling(keys, client):238    """Events published after subscribe arrive through XREAD BLOCK; events before a resume token are skipped;239    unsubscribe stops delivery (no reader), resubscribe with a token replays."""240    from stream import broker241    with client.websocket_connect(f"/v1/stream?api_key={keys[0]}") as ws:242        ws.receive_json()243        ws.send_json({"action": "subscribe", "tickers": ["NVDA"]})244        ws.receive_json()245        s1 = broker.publish({"type": "filing", "ticker": "NVDA", "form": "8-K", "accn": "b1"})246        assert ws.receive_json()["accn"] == "b1"247        ws.send_json({"action": "unsubscribe"})248        assert ws.receive_json()["type"] == "unsubscribed"249        s2 = broker.publish({"type": "filing", "ticker": "NVDA", "form": "8-K", "accn": "b2"})250        ws.send_json({"action": "ping"})251        assert ws.receive_json()["type"] == "pong"                     # nothing delivered while unsubscribed252        ws.send_json({"action": "subscribe", "tickers": ["NVDA"], "resume_token": s1})253        assert ws.receive_json()["resume_from"] == s1254        assert ws.receive_json()["accn"] == "b2" and s2 > s1255        s3 = broker.publish({"type": "filing", "ticker": "NVDA", "form": "8-K", "accn": "b3"})256        got = ws.receive_json()257        assert got["accn"] == "b3" and got["seq"] == s3258259260def test_accounting_hook_interface(app):261    from stream import accounting262    calls = []263    accounting.set_charger(lambda principal, rows: calls.append((principal, rows)))264    try:265        accounting.charge("key:abc", 1)266        assert calls == [("key:abc", 1)] and accounting.get_charger() is not None267    finally:268        accounting.set_charger(None)269270271def test_stream_info(client_hu):272    r = client_hu.get("/v1/stream/info")273    assert r.status_code == 200274    d = r.json()["data"]275    assert d["channels"] == ["filings"] and d["close_codes"]["4001"] == "AUTH_REQUIRED" and d["url"].startswith("wss://")276