|
1 |
+"""`GET /v1/stream` — WebSocket delivering SEC filing events (channel `filings`). |
|
2 |
+ |
|
3 |
+Protocol (AsyncAPI 3 spec in docs/asyncapi.yaml): |
|
4 |
+ |
|
5 |
+* connect with `?api_key=…` or `Authorization: Bearer …`; keyless → `{"type":"error","code":"AUTH_REQUIRED"}` |
|
6 |
+ then close 4001; more than 5 concurrent connections per key → `STREAM_CONNECTION_LIMIT`, close 4029; |
|
7 |
+* server → `{"type":"hello","seq":<last seq>,"heartbeat_seconds":20}`; |
|
8 |
+* client → `{"action":"subscribe","channel":"filings","tickers":["AAPL"]|"all","forms":["10-K","10-Q"],"resume_token":<seq>}` |
|
9 |
+ → server `{"type":"subscribed",…}` then replays buffered events with seq > resume_token (last 1 000 kept); |
|
10 |
+* server → `{"type":"filing", ticker, cik, form, period, filed_date, url, accn, summary:{…}, seq}`; |
|
11 |
+* server → `{"type":"heartbeat","ts":…,"seq":…}` every 20 s; client `{"action":"ping"}` → `{"type":"pong"}`; |
|
12 |
+* each delivered `filing` message is charged 1 row through `stream.accounting.charge(principal, 1)`. |
|
13 |
+ |
|
14 |
+Author: Simon-Pierre Boucher <contact@spboucher.ai> |
|
15 |
+""" |
|
16 |
+from __future__ import annotations |
|
17 |
+ |
|
18 |
+import asyncio |
|
19 |
+import json |
|
20 |
+import logging |
|
21 |
+import time |
|
22 |
+from datetime import datetime, timezone |
|
23 |
+from typing import Any |
|
24 |
+ |
|
25 |
+from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect |
|
26 |
+from starlette.websockets import WebSocketState |
|
27 |
+ |
|
28 |
+from core.errors import CODES, docs_link |
|
29 |
+from core.responses import json_response |
|
30 |
+ |
|
31 |
+from . import accounting, broker |
|
32 |
+from .auth import authenticate, extract_key |
|
33 |
+ |
|
34 |
+log = logging.getLogger("hfmarketdata.stream") |
|
35 |
+ |
|
36 |
+router = APIRouter(tags=["stream"]) |
|
37 |
+ |
|
38 |
+HEARTBEAT_SECONDS = 20.0 |
|
39 |
+POLL_SECONDS = 0.25 |
|
40 |
+MAX_CONNECTIONS_PER_KEY = 5 |
|
41 |
+CONN_TTL_SECONDS = 3600 |
|
42 |
+CLOSE_AUTH, CLOSE_LIMIT, CLOSE_PROTOCOL = 4001, 4029, 4400 |
|
43 |
+CHANNELS = ("filings",) |
|
44 |
+ |
|
45 |
+ |
|
46 |
+def _err(code: str, message: str, **extra: Any) -> dict[str, Any]: |
|
47 |
+ return {"type": "error", "code": code, "message": message, "docs": docs_link(code), **extra} |
|
48 |
+ |
|
49 |
+ |
|
50 |
+def _now() -> str: |
|
51 |
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") |
|
52 |
+ |
|
53 |
+ |
|
54 |
+class Subscription: |
|
55 |
+ def __init__(self): |
|
56 |
+ self.channel: str | None = None |
|
57 |
+ self.tickers: set[str] | None = None # None = all |
|
58 |
+ self.forms: set[str] | None = None # None = all |
|
59 |
+ |
|
60 |
+ def matches(self, ev: dict[str, Any]) -> bool: |
|
61 |
+ if self.channel != "filings" or ev.get("type") != "filing": |
|
62 |
+ return False |
|
63 |
+ if self.tickers is not None and str(ev.get("ticker", "")).upper() not in self.tickers: |
|
64 |
+ return False |
|
65 |
+ if self.forms is not None and str(ev.get("form", "")).upper() not in self.forms: |
|
66 |
+ return False |
|
67 |
+ return True |
|
68 |
+ |
|
69 |
+ |
|
70 |
+@router.get("/v1/stream/info", summary="WebSocket protocol summary", |
|
71 |
+ description="Human/machine readable description of the `GET /v1/stream` WebSocket (the full AsyncAPI 3 " |
|
72 |
+ "document is served by the docs site at `/docs/asyncapi.yaml`). Useful to discover channels, " |
|
73 |
+ "close codes and the heartbeat interval without opening a socket.", |
|
74 |
+ responses={200: {"description": "Protocol summary", "content": {"application/json": {"example": { |
|
75 |
+ "data": {"url": "wss://www.hfmarketdata.io/v1/stream", "channels": ["filings"], "heartbeat_seconds": 20, |
|
76 |
+ "max_connections_per_key": 5, "buffer": 1000, "close_codes": {"4001": "AUTH_REQUIRED", |
|
77 |
+ "4029": "STREAM_CONNECTION_LIMIT", "4400": "protocol error"}}, "meta": {"count": 1}}}}}}, |
|
78 |
+ openapi_extra={"x-errors": []}) |
|
79 |
+def stream_info(): |
|
80 |
+ from core.config import settings |
|
81 |
+ return json_response({"url": settings.public_url.replace("https://", "wss://").replace("http://", "ws://") + "/v1/stream", |
|
82 |
+ "auth": ["?api_key=<key>", "Authorization: Bearer <key>"], "channels": list(CHANNELS), |
|
83 |
+ "subscribe": {"action": "subscribe", "channel": "filings", "tickers": ["AAPL", "MSFT"], |
|
84 |
+ "forms": ["10-K", "10-Q", "8-K", "20-F"], "resume_token": 123}, |
|
85 |
+ "message_types": ["hello", "subscribed", "filing", "heartbeat", "pong", "error"], |
|
86 |
+ "heartbeat_seconds": HEARTBEAT_SECONDS, "max_connections_per_key": MAX_CONNECTIONS_PER_KEY, |
|
87 |
+ "buffer": broker.MAXLEN, "rows_per_message": accounting.REDUCED_RATE_ROWS_PER_MESSAGE, |
|
88 |
+ "close_codes": {str(CLOSE_AUTH): "AUTH_REQUIRED", str(CLOSE_LIMIT): "STREAM_CONNECTION_LIMIT", |
|
89 |
+ str(CLOSE_PROTOCOL): "protocol error"}, |
|
90 |
+ "asyncapi": settings.public_url + "/docs/asyncapi.yaml"}) |
|
91 |
+ |
|
92 |
+ |
|
93 |
+@router.websocket("/v1/stream") |
|
94 |
+async def stream_ws(ws: WebSocket, api_key: str | None = Query(None)): |
|
95 |
+ key = extract_key(api_key, ws.headers.get("authorization")) |
|
96 |
+ principal = authenticate(key) |
|
97 |
+ await ws.accept() |
|
98 |
+ if principal is None: |
|
99 |
+ await ws.send_json(_err("AUTH_REQUIRED", "A valid API key is required for the stream: pass ?api_key=… or " |
|
100 |
+ "Authorization: Bearer …. Create a free key on the dashboard.")) |
|
101 |
+ await ws.close(code=CLOSE_AUTH, reason="AUTH_REQUIRED") |
|
102 |
+ return |
|
103 |
+ r = broker.get_redis() |
|
104 |
+ conn_key = f"stream:conns:{principal}" |
|
105 |
+ n = int(r.incr(conn_key)) |
|
106 |
+ r.expire(conn_key, CONN_TTL_SECONDS) |
|
107 |
+ if n > MAX_CONNECTIONS_PER_KEY: |
|
108 |
+ r.decr(conn_key) |
|
109 |
+ await ws.send_json(_err("STREAM_CONNECTION_LIMIT", CODES["STREAM_CONNECTION_LIMIT"], limit=MAX_CONNECTIONS_PER_KEY)) |
|
110 |
+ await ws.close(code=CLOSE_LIMIT, reason="STREAM_CONNECTION_LIMIT") |
|
111 |
+ return |
|
112 |
+ ar = broker.get_async_redis() |
|
113 |
+ sub = Subscription() |
|
114 |
+ last_seq = broker.current_seq() |
|
115 |
+ inbox: asyncio.Queue = asyncio.Queue() |
|
116 |
+ |
|
117 |
+ async def receiver(): |
|
118 |
+ try: |
|
119 |
+ while True: |
|
120 |
+ msg = await ws.receive_text() |
|
121 |
+ await inbox.put(msg) |
|
122 |
+ except WebSocketDisconnect: |
|
123 |
+ await inbox.put(None) |
|
124 |
+ except Exception: |
|
125 |
+ await inbox.put(None) |
|
126 |
+ |
|
127 |
+ recv_task = asyncio.create_task(receiver()) |
|
128 |
+ try: |
|
129 |
+ await ws.send_json({"type": "hello", "seq": last_seq, "heartbeat_seconds": HEARTBEAT_SECONDS, |
|
130 |
+ "max_connections_per_key": MAX_CONNECTIONS_PER_KEY, "buffer": broker.MAXLEN, "ts": _now()}) |
|
131 |
+ last_beat = time.monotonic() |
|
132 |
+ while True: |
|
133 |
+ try: |
|
134 |
+ raw = await asyncio.wait_for(inbox.get(), timeout=POLL_SECONDS) |
|
135 |
+ if raw is None: |
|
136 |
+ break |
|
137 |
+ seq_box = [last_seq] |
|
138 |
+ await _handle(ws, raw, sub, ar, principal, seq_box) |
|
139 |
+ last_seq = seq_box[0] |
|
140 |
+ except asyncio.TimeoutError: |
|
141 |
+ pass |
|
142 |
+ if sub.channel: |
|
143 |
+ events = await broker.read_since(ar, last_seq) |
|
144 |
+ for ev in events: |
|
145 |
+ last_seq = max(last_seq, int(ev.get("seq", last_seq))) |
|
146 |
+ if sub.matches(ev): |
|
147 |
+ await ws.send_json(ev) |
|
148 |
+ accounting.charge(principal, accounting.REDUCED_RATE_ROWS_PER_MESSAGE) |
|
149 |
+ if time.monotonic() - last_beat >= HEARTBEAT_SECONDS: |
|
150 |
+ await ws.send_json({"type": "heartbeat", "ts": _now(), "seq": last_seq}) |
|
151 |
+ r.expire(conn_key, CONN_TTL_SECONDS) |
|
152 |
+ last_beat = time.monotonic() |
|
153 |
+ except WebSocketDisconnect: |
|
154 |
+ pass |
|
155 |
+ except Exception as e: # pragma: no cover |
|
156 |
+ log.exception("stream error: %s", e) |
|
157 |
+ finally: |
|
158 |
+ recv_task.cancel() |
|
159 |
+ try: |
|
160 |
+ r.decr(conn_key) |
|
161 |
+ except Exception: |
|
162 |
+ pass |
|
163 |
+ try: |
|
164 |
+ await ar.aclose() if hasattr(ar, "aclose") else await ar.close() |
|
165 |
+ except Exception: |
|
166 |
+ pass |
|
167 |
+ if ws.client_state == WebSocketState.CONNECTED: |
|
168 |
+ try: |
|
169 |
+ await ws.close() |
|
170 |
+ except Exception: |
|
171 |
+ pass |
|
172 |
+ |
|
173 |
+ |
|
174 |
+async def _handle(ws: WebSocket, raw: str, sub: Subscription, ar, principal: str, seq_box: list[int]) -> None: |
|
175 |
+ try: |
|
176 |
+ msg = json.loads(raw) |
|
177 |
+ except json.JSONDecodeError: |
|
178 |
+ await ws.send_json(_err("VALIDATION_ERROR", "messages must be JSON objects")) |
|
179 |
+ return |
|
180 |
+ if not isinstance(msg, dict): |
|
181 |
+ await ws.send_json(_err("VALIDATION_ERROR", "messages must be JSON objects")) |
|
182 |
+ return |
|
183 |
+ action = str(msg.get("action") or msg.get("type") or "").lower() |
|
184 |
+ if action == "ping": |
|
185 |
+ await ws.send_json({"type": "pong", "ts": _now(), "seq": seq_box[0]}) |
|
186 |
+ return |
|
187 |
+ if action == "unsubscribe": |
|
188 |
+ sub.channel = None |
|
189 |
+ await ws.send_json({"type": "unsubscribed", "ts": _now()}) |
|
190 |
+ return |
|
191 |
+ if action != "subscribe": |
|
192 |
+ await ws.send_json(_err("VALIDATION_ERROR", f"unknown action '{action}' (subscribe, unsubscribe, ping)")) |
|
193 |
+ return |
|
194 |
+ channel = str(msg.get("channel") or "filings").lower() |
|
195 |
+ if channel not in CHANNELS: |
|
196 |
+ await ws.send_json(_err("NOT_FOUND", f"unknown channel '{channel}'", channels=list(CHANNELS))) |
|
197 |
+ return |
|
198 |
+ tickers = msg.get("tickers", "all") |
|
199 |
+ forms = msg.get("forms") |
|
200 |
+ if isinstance(tickers, str) and tickers.lower() == "all": |
|
201 |
+ sub.tickers = None |
|
202 |
+ elif isinstance(tickers, list) and all(isinstance(t, str) for t in tickers) and tickers: |
|
203 |
+ sub.tickers = {t.upper() for t in tickers} |
|
204 |
+ else: |
|
205 |
+ await ws.send_json(_err("VALIDATION_ERROR", "tickers must be a non-empty list of strings or \"all\"")) |
|
206 |
+ return |
|
207 |
+ if forms in (None, "all"): |
|
208 |
+ sub.forms = None |
|
209 |
+ elif isinstance(forms, list) and all(isinstance(f, str) for f in forms) and forms: |
|
210 |
+ sub.forms = {f.upper() for f in forms} |
|
211 |
+ else: |
|
212 |
+ await ws.send_json(_err("VALIDATION_ERROR", "forms must be a non-empty list of strings or omitted")) |
|
213 |
+ return |
|
214 |
+ sub.channel = channel |
|
215 |
+ resume = msg.get("resume_token") |
|
216 |
+ if resume is not None: |
|
217 |
+ try: |
|
218 |
+ seq_box[0] = int(resume) |
|
219 |
+ except (TypeError, ValueError): |
|
220 |
+ await ws.send_json(_err("VALIDATION_ERROR", "resume_token must be an integer (last seq received)")) |
|
221 |
+ return |
|
222 |
+ await ws.send_json({"type": "subscribed", "channel": channel, "tickers": sorted(sub.tickers) if sub.tickers else "all", |
|
223 |
+ "forms": sorted(sub.forms) if sub.forms else "all", "resume_from": seq_box[0], "ts": _now()}) |
|
224 |
|