vexity 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vexity/__init__.py +41 -0
- vexity/client.py +259 -0
- vexity/client_contract_gen.py +241 -0
- vexity/client_policy_gen.py +319 -0
- vexity/compact/__init__.py +0 -0
- vexity/compact/append.py +126 -0
- vexity/compact/append_gen.py +31 -0
- vexity/compact/block.py +198 -0
- vexity/compact/decode.py +328 -0
- vexity/compact/layout_gen.py +879 -0
- vexity/errors.py +72 -0
- vexity/etag_store.py +108 -0
- vexity/live/__init__.py +5 -0
- vexity/live/session.py +332 -0
- vexity/live/socket.py +82 -0
- vexity/pacing.py +49 -0
- vexity/py.typed +0 -0
- vexity/windows.py +161 -0
- vexity-0.1.0.dist-info/METADATA +79 -0
- vexity-0.1.0.dist-info/RECORD +22 -0
- vexity-0.1.0.dist-info/WHEEL +4 -0
- vexity-0.1.0.dist-info/licenses/LICENSE +25 -0
vexity/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Official Python client for the Vexity options-analytics API."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from vexity.client import Response, Vexity
|
|
6
|
+
from vexity.errors import (
|
|
7
|
+
AuthError,
|
|
8
|
+
BadRequest,
|
|
9
|
+
DecodeError,
|
|
10
|
+
Forbidden,
|
|
11
|
+
LayoutMismatch,
|
|
12
|
+
NotFound,
|
|
13
|
+
PlanError,
|
|
14
|
+
RateLimited,
|
|
15
|
+
ServerError,
|
|
16
|
+
SocketClosed,
|
|
17
|
+
TransportError,
|
|
18
|
+
VexityError,
|
|
19
|
+
)
|
|
20
|
+
from vexity.etag_store import EtagStore
|
|
21
|
+
from vexity.windows import Update, Window
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AuthError",
|
|
25
|
+
"BadRequest",
|
|
26
|
+
"DecodeError",
|
|
27
|
+
"EtagStore",
|
|
28
|
+
"Forbidden",
|
|
29
|
+
"LayoutMismatch",
|
|
30
|
+
"NotFound",
|
|
31
|
+
"PlanError",
|
|
32
|
+
"RateLimited",
|
|
33
|
+
"Response",
|
|
34
|
+
"ServerError",
|
|
35
|
+
"SocketClosed",
|
|
36
|
+
"TransportError",
|
|
37
|
+
"Update",
|
|
38
|
+
"Vexity",
|
|
39
|
+
"VexityError",
|
|
40
|
+
"Window",
|
|
41
|
+
]
|
vexity/client.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""The request pipeline: auth, `If-None-Match`, immutable reads from the store, compact bodies at the pinned layout, pacing, retries and typed errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
import vexity
|
|
20
|
+
from vexity.client_contract_gen import COMPACT_LAYOUT_PARAM, COMPACT_MEDIA_TYPE, COMPACT_PARAM, COMPACT_PLUGIN, RateBudget, compact_body_of
|
|
21
|
+
from vexity.client_policy_gen import backoff_base_ms, backoff_cap_ms, backoff_ceiling_ms, classify_http
|
|
22
|
+
from vexity.compact.decode import COMPACT_LAYOUT
|
|
23
|
+
from vexity.compact.layout_gen import COMPACT_LAYOUT_ID, COMPACT_LAYOUTS
|
|
24
|
+
from vexity.errors import ACTION_ERRORS, DecodeError, LayoutMismatch, TransportError, VexityError
|
|
25
|
+
from vexity.etag_store import EtagStore, Stored
|
|
26
|
+
from vexity.pacing import Pacer
|
|
27
|
+
from vexity.windows import Window
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://vexity.net"
|
|
30
|
+
V2_PREFIX = "/api/v2/"
|
|
31
|
+
COMPACT_LAYOUT_ID_PARAM = "layout_id"
|
|
32
|
+
_LAYOUT_ETAG = re.compile(rf'-cpb{COMPACT_LAYOUT}(-[a-z]+)?"$')
|
|
33
|
+
_APP = re.compile(r"[\x21-\x7e][\x20-\x7e]{0,62}[\x21-\x7e]|[\x21-\x7e]")
|
|
34
|
+
_MAX_AGE = re.compile(r"max-age=([0-9]+)")
|
|
35
|
+
_QUERY_SAFE = ",:-._~"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Response:
|
|
40
|
+
"""One answer. `source` is `network` (200), `revalidated` (304, stored body) or `store` (immutable, no request made). `layout` is the compact layout the body was read at, None for JSON."""
|
|
41
|
+
|
|
42
|
+
body: Any
|
|
43
|
+
status: int
|
|
44
|
+
source: Literal["network", "revalidated", "store"]
|
|
45
|
+
etag: str
|
|
46
|
+
budget: RateBudget | None
|
|
47
|
+
request_id: str
|
|
48
|
+
layout: int | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def user_agent(app: str | None) -> str:
|
|
52
|
+
"""`vexity-py/<version>`, then the caller's own `app` name (`myapp/1.2`) so heavy users can be reached."""
|
|
53
|
+
if app is not None and not _APP.fullmatch(app):
|
|
54
|
+
raise ValueError("app is 1 to 64 printable ASCII characters, e.g. 'myapp/1.2'")
|
|
55
|
+
return f"vexity-py/{vexity.__version__}" + (f" {app}" if app else "")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def wire_params(params: Mapping[str, Any] | None) -> dict[str, str]:
|
|
59
|
+
"""The query values as the server reads them: a list joins with commas, True is `true`, None drops, `from_` is `from`."""
|
|
60
|
+
out: dict[str, str] = {}
|
|
61
|
+
for key, value in (params or {}).items():
|
|
62
|
+
if value is None:
|
|
63
|
+
continue
|
|
64
|
+
if isinstance(value, bool):
|
|
65
|
+
value = "true" if value else "false"
|
|
66
|
+
elif isinstance(value, (list, tuple)):
|
|
67
|
+
value = ",".join(str(v) for v in value)
|
|
68
|
+
out[key.rstrip("_")] = str(value)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def canonical_query(params: Mapping[str, Any] | None) -> str:
|
|
73
|
+
"""Sorted, percent-escaped `k=v` pairs of `wire_params`."""
|
|
74
|
+
return "&".join(f"{k}={quote(v, safe=_QUERY_SAFE)}" for k, v in sorted(wire_params(params).items()))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _fresh_until(headers: httpx.Headers) -> float:
|
|
78
|
+
cache = headers.get("cache-control", "")
|
|
79
|
+
found = _MAX_AGE.search(cache)
|
|
80
|
+
return time.time() + int(found[1]) if found and "immutable" in cache and "no-store" not in cache else 0.0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Vexity:
|
|
84
|
+
"""Synchronous client. Instant reads ride compact protobuf at the pinned layout (every member, freshness pairs included); `compact=False` reads JSON instead."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
api_key: str | None = None,
|
|
89
|
+
*,
|
|
90
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
91
|
+
compact: bool = True,
|
|
92
|
+
app: str | None = None,
|
|
93
|
+
store: EtagStore | None = None,
|
|
94
|
+
max_retries: int = 3,
|
|
95
|
+
max_wait_s: float = 120.0,
|
|
96
|
+
timeout_s: float = 30.0,
|
|
97
|
+
transport: httpx.BaseTransport | None = None,
|
|
98
|
+
):
|
|
99
|
+
key = api_key or os.environ.get("VEXITY_API_KEY", "")
|
|
100
|
+
if not key:
|
|
101
|
+
raise ValueError("no API key: pass api_key= or set VEXITY_API_KEY")
|
|
102
|
+
self._key = key
|
|
103
|
+
self.base_url = base_url.rstrip("/")
|
|
104
|
+
self._key_tag = hashlib.sha256(f"{self.base_url}\n{key}".encode()).hexdigest()[:16]
|
|
105
|
+
self.compact = compact
|
|
106
|
+
self.store = store if store is not None else EtagStore()
|
|
107
|
+
self._max_retries = max_retries
|
|
108
|
+
self.pacer = Pacer(max_wait_s=max_wait_s)
|
|
109
|
+
self.user_agent = user_agent(app)
|
|
110
|
+
self._http = httpx.Client(base_url=self.base_url, timeout=timeout_s, transport=transport, headers={"Authorization": f"Bearer {key}", "User-Agent": self.user_agent})
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def api_key(self) -> str:
|
|
114
|
+
"""The credential, for the live socket URL only; never logged and masked in `repr`."""
|
|
115
|
+
return self._key
|
|
116
|
+
|
|
117
|
+
def __repr__(self) -> str:
|
|
118
|
+
return f"Vexity(base_url={self.base_url!r}, api_key=***)"
|
|
119
|
+
|
|
120
|
+
def __enter__(self) -> Vexity:
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None:
|
|
124
|
+
self.close()
|
|
125
|
+
|
|
126
|
+
def close(self) -> None:
|
|
127
|
+
self._http.close()
|
|
128
|
+
|
|
129
|
+
def get(self, path: str, params: Mapping[str, Any] | None = None, *, compact: bool | None = None) -> Response:
|
|
130
|
+
"""GET `path` (a v2 path such as `/api/v2/levels/SPX`) with the efficient defaults."""
|
|
131
|
+
query = canonical_query(params)
|
|
132
|
+
resource = path[len(V2_PREFIX) :].split("/", 1)[0] if path.startswith(V2_PREFIX) else ""
|
|
133
|
+
asked = wire_params(params)
|
|
134
|
+
body_name = compact_body_of(resource, lambda key: asked.get(key, "")) if (self.compact if compact is None else compact) else None
|
|
135
|
+
if body_name not in COMPACT_LAYOUTS:
|
|
136
|
+
body_name = None
|
|
137
|
+
if body_name is not None:
|
|
138
|
+
query = canonical_query({**(params or {}), COMPACT_PARAM: COMPACT_PLUGIN, COMPACT_LAYOUT_PARAM: COMPACT_LAYOUT, COMPACT_LAYOUT_ID_PARAM: COMPACT_LAYOUT_ID})
|
|
139
|
+
url = f"{path}?{query}" if query else path
|
|
140
|
+
key = f"{self._key_tag}|{url}|{'compact' if body_name else 'json'}"
|
|
141
|
+
stored = self.store.get(key)
|
|
142
|
+
if stored is not None and stored.fresh_until > time.time():
|
|
143
|
+
return self._response(stored, 200, "store", "", body_name)
|
|
144
|
+
headers = {"Accept": COMPACT_MEDIA_TYPE if body_name else "application/json"}
|
|
145
|
+
if stored is not None:
|
|
146
|
+
headers["If-None-Match"] = stored.etag
|
|
147
|
+
resp = self._send("GET", url, headers)
|
|
148
|
+
request_id = resp.headers.get("x-request-id", "")
|
|
149
|
+
if resp.status_code == 304 and stored is not None:
|
|
150
|
+
return self._response(stored, 304, "revalidated", request_id, body_name)
|
|
151
|
+
if resp.status_code != 200:
|
|
152
|
+
raise DecodeError(f"{path} answered {resp.status_code}, which is not an answer to this read", status=resp.status_code, request_id=request_id)
|
|
153
|
+
if body_name is not None and not _LAYOUT_ETAG.search(resp.headers.get("etag", "")):
|
|
154
|
+
raise LayoutMismatch(f"asked compact layout {COMPACT_LAYOUT} and the server answered another ({resp.headers.get('etag', '')!r}): upgrade vexity or pass compact=False", status=200, request_id=request_id)
|
|
155
|
+
fresh = Stored(resp.headers.get("etag", ""), resp.content, resp.headers.get("content-type", ""), _fresh_until(resp.headers), COMPACT_LAYOUT if body_name else None)
|
|
156
|
+
if fresh.etag and "no-store" not in resp.headers.get("cache-control", ""):
|
|
157
|
+
self.store.put(key, fresh)
|
|
158
|
+
return self._response(fresh, resp.status_code, "network", request_id, body_name)
|
|
159
|
+
|
|
160
|
+
def post(self, path: str, body: Mapping[str, Any]) -> Any:
|
|
161
|
+
resp = self._send("POST", path, {"Accept": "application/json"}, json_body=body)
|
|
162
|
+
try:
|
|
163
|
+
return resp.json()
|
|
164
|
+
except ValueError as e:
|
|
165
|
+
raise DecodeError(f"{path} answered a body that is not JSON", status=resp.status_code) from e
|
|
166
|
+
|
|
167
|
+
def read(self, resource: str, ticker: str, /, *, compact: bool | None = None, **params: Any) -> Any:
|
|
168
|
+
"""The body of `/api/v2/{resource}/{ticker}`."""
|
|
169
|
+
return self.get(f"{V2_PREFIX}{resource}/{ticker}", params, compact=compact).body
|
|
170
|
+
|
|
171
|
+
def levels(self, ticker: str, **params: Any) -> Any:
|
|
172
|
+
return self.read("levels", ticker, **params)
|
|
173
|
+
|
|
174
|
+
def frame(self, ticker: str, **params: Any) -> Any:
|
|
175
|
+
return self.read("frame", ticker, **params)
|
|
176
|
+
|
|
177
|
+
def profile(self, ticker: str, **params: Any) -> Any:
|
|
178
|
+
return self.read("profile", ticker, **params)
|
|
179
|
+
|
|
180
|
+
def series(self, ticker: str, **params: Any) -> Any:
|
|
181
|
+
return self.read("series", ticker, **params)
|
|
182
|
+
|
|
183
|
+
def contracts(self, ticker: str, **params: Any) -> Any:
|
|
184
|
+
return self.read("contracts", ticker, **params)
|
|
185
|
+
|
|
186
|
+
def surface(self, ticker: str, **params: Any) -> Any:
|
|
187
|
+
return self.read("surface", ticker, **params)
|
|
188
|
+
|
|
189
|
+
def me(self) -> Any:
|
|
190
|
+
return self.get(f"{V2_PREFIX}me").body
|
|
191
|
+
|
|
192
|
+
def catalogue(self, **params: Any) -> Any:
|
|
193
|
+
return self.get(f"{V2_PREFIX}catalogue", params).body
|
|
194
|
+
|
|
195
|
+
def window(self, resource: str, ticker: str, /, *, span: str | None = None, **params: Any) -> Window:
|
|
196
|
+
"""A live window read once in full, then by tails: see `Window`."""
|
|
197
|
+
return Window(self, resource, ticker, span=span, params=params)
|
|
198
|
+
|
|
199
|
+
def live(self, **options: Any):
|
|
200
|
+
"""A live socket session: see `vexity.live.Live`."""
|
|
201
|
+
from vexity.live.session import Live
|
|
202
|
+
|
|
203
|
+
return Live(self, **options)
|
|
204
|
+
|
|
205
|
+
def _response(self, stored: Stored, status: int, source: Literal["network", "revalidated", "store"], request_id: str, body_name: str | None) -> Response:
|
|
206
|
+
if body_name is None:
|
|
207
|
+
try:
|
|
208
|
+
body = json.loads(stored.body)
|
|
209
|
+
except ValueError as e:
|
|
210
|
+
raise DecodeError("the answer is not JSON", status=status, request_id=request_id) from e
|
|
211
|
+
else:
|
|
212
|
+
from vexity.compact.decode import CompactError, decode_compact
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
body = decode_compact(body_name, stored.body)
|
|
216
|
+
except CompactError as e:
|
|
217
|
+
raise DecodeError(f"the compact answer does not read as {body_name}: {e}", status=status, request_id=request_id) from e
|
|
218
|
+
return Response(body, status, source, stored.etag, self.pacer.budget, request_id, stored.layout)
|
|
219
|
+
|
|
220
|
+
def _send(self, method: str, url: str, headers: dict[str, str], *, json_body: Mapping[str, Any] | None = None) -> httpx.Response:
|
|
221
|
+
failures = 0
|
|
222
|
+
while True:
|
|
223
|
+
self.pacer.wait()
|
|
224
|
+
resp: httpx.Response | None = None
|
|
225
|
+
try:
|
|
226
|
+
resp = self._http.request(method, url, headers=headers, json=json_body)
|
|
227
|
+
except httpx.TransportError as e:
|
|
228
|
+
status, reason, detail, request_id, retry_ms = 0, "", str(e) or type(e).__name__, "", 0
|
|
229
|
+
else:
|
|
230
|
+
self.pacer.observe({k.lower(): v for k, v in resp.headers.items()})
|
|
231
|
+
if resp.status_code < 400:
|
|
232
|
+
return resp
|
|
233
|
+
status = resp.status_code
|
|
234
|
+
reason, detail, request_id = _error_fields(resp)
|
|
235
|
+
if status == 409 and detail.startswith("layout_mismatch"):
|
|
236
|
+
raise LayoutMismatch(f"the server writes another compact layout than this vexity release reads: upgrade vexity or pass compact=False ({detail})", status=409, reason=reason, detail=detail, request_id=request_id)
|
|
237
|
+
retry_ms = self.pacer.retry_after({k.lower(): v for k, v in resp.headers.items()})
|
|
238
|
+
decision = classify_http(status, reason, retry_ms, "mint")
|
|
239
|
+
error_class = ACTION_ERRORS[decision.action] if status else TransportError
|
|
240
|
+
if decision.action in ("rate", "backoff") and failures < self._max_retries and not self.pacer.too_long(decision.wait_ms):
|
|
241
|
+
failures += 1
|
|
242
|
+
delay = decision.wait_ms if decision.action == "rate" else max(decision.wait_ms, int(random.uniform(0, backoff_ceiling_ms(failures, backoff_base_ms(), backoff_cap_ms()))))
|
|
243
|
+
self.pacer.block(delay)
|
|
244
|
+
continue
|
|
245
|
+
raise error_class(f"{method} {url.split('?', 1)[0]} answered {status or 'no status'} {reason}".strip(), status=status, reason=reason, detail=detail, request_id=request_id, retry_after_ms=retry_ms)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _error_fields(resp: httpx.Response) -> tuple[str, str, str]:
|
|
249
|
+
request_id = resp.headers.get("x-request-id", "")
|
|
250
|
+
try:
|
|
251
|
+
body = resp.json()
|
|
252
|
+
except ValueError:
|
|
253
|
+
return "", resp.text[:200], request_id
|
|
254
|
+
if not isinstance(body, dict):
|
|
255
|
+
return "", "", request_id
|
|
256
|
+
return str(body.get("reason", "")), str(body.get("detail", "")), str(body.get("request_id", request_id))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
__all__ = ["Response", "Vexity", "VexityError", "canonical_query"]
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Generated by integrations/codegen/gen_v2_decoders.py from services/vexity-rs/crates/vx-core/catalogue.json (client_contract). Do not edit.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
RETRY_AFTER_HEADER = "Retry-After"
|
|
10
|
+
RATE_LIMIT_HEADER = "RateLimit"
|
|
11
|
+
RATE_LIMIT_POLICY_HEADER = "RateLimit-Policy"
|
|
12
|
+
COMPACT_PARAM = "compact"
|
|
13
|
+
COMPACT_LAYOUT_PARAM = "compact_layout"
|
|
14
|
+
COMPACT_PLUGIN = "plugin"
|
|
15
|
+
COMPACT_MEDIA_TYPE = "application/x-protobuf"
|
|
16
|
+
FRAME_WINDOW_SLACK = 8
|
|
17
|
+
FRAME_STEP_MS = {"cycle": 30000, "5s": 5000, "10s": 10000, "30s": 30000, "1m": 60000, "5m": 300000, "15m": 900000, "30m": 1800000, "1h": 3600000, "1d": 86400000}
|
|
18
|
+
|
|
19
|
+
_RETRY_AFTER_MIN_MS = 1000
|
|
20
|
+
_RETRY_AFTER_MAX_MS = 3600000
|
|
21
|
+
_IMF_FIXDATE = re.compile(r"^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), ([0-9]{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([12][0-9]{3}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$")
|
|
22
|
+
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
23
|
+
_DIGITS = re.compile(r"[0-9]+")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _days_from_civil(y: int, m: int, d: int) -> int:
|
|
27
|
+
y -= m <= 2
|
|
28
|
+
era = y // 400
|
|
29
|
+
yoe = y - era * 400
|
|
30
|
+
doy = (153 * (m + (-3 if m > 2 else 9)) + 2) // 5 + d - 1
|
|
31
|
+
doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
|
|
32
|
+
return era * 146097 + doe - 719468
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _clamp(ms: int) -> int:
|
|
36
|
+
return max(_RETRY_AFTER_MIN_MS, min(_RETRY_AFTER_MAX_MS, ms))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def retry_after_ms(value: str | None, now_ms: int) -> int:
|
|
40
|
+
"""A Retry-After as a wait in ms: whole seconds or an IMF-fixdate, clamped to the contract's floor and ceiling; 0 when it is empty or neither."""
|
|
41
|
+
raw = (value or "").strip(" \t")
|
|
42
|
+
if raw == "":
|
|
43
|
+
return 0
|
|
44
|
+
if _DIGITS.fullmatch(raw):
|
|
45
|
+
return _clamp(_RETRY_AFTER_MAX_MS if len(raw) > 12 else int(raw) * 1000)
|
|
46
|
+
m = _IMF_FIXDATE.fullmatch(raw)
|
|
47
|
+
if m is None:
|
|
48
|
+
return 0
|
|
49
|
+
day, hour, minute, second = int(m[1]), int(m[4]), int(m[5]), int(m[6])
|
|
50
|
+
if day < 1 or day > 31 or hour > 23 or minute > 59 or second > 60:
|
|
51
|
+
return 0
|
|
52
|
+
at_ms = (_days_from_civil(int(m[3]), _MONTHS.index(m[2]) + 1, day) * 86400 + hour * 3600 + minute * 60 + second) * 1000
|
|
53
|
+
return _clamp(-((now_ms - at_ms) // 1000) * 1000 if at_ms > now_ms else 0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class RateBudget:
|
|
58
|
+
"""The first budget the RateLimit (what is left, the seconds until the window frees a unit) and RateLimit-Policy (quota per window) headers list; a member they omit is None."""
|
|
59
|
+
|
|
60
|
+
quota: int | None
|
|
61
|
+
window_s: int | None
|
|
62
|
+
left: int | None
|
|
63
|
+
reset_s: int | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _first_int(text: str, key: str) -> int | None:
|
|
67
|
+
m = re.search(rf"\b{key}=([0-9]+)", text)
|
|
68
|
+
return None if m is None else int(m[1])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def rate_budget(ratelimit: str | None, policy: str | None) -> RateBudget | None:
|
|
72
|
+
state = ratelimit or ""
|
|
73
|
+
quota = policy or ""
|
|
74
|
+
if state == "" and quota == "":
|
|
75
|
+
return None
|
|
76
|
+
return RateBudget(_first_int(quota, "q"), _first_int(quota, "w"), _first_int(state, "r"), _first_int(state, "t"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def compact_body_of(resource: str, param: Callable[[str], str]) -> str | None:
|
|
80
|
+
"""The compact body a read of `resource` is asked for, or None to stay JSON; `param` is the read's value of a query key, '' when absent. The first matching shape decides."""
|
|
81
|
+
if resource == "profile":
|
|
82
|
+
if param("by") == "time":
|
|
83
|
+
return "ProfileWindowBody"
|
|
84
|
+
return "ProfileBody"
|
|
85
|
+
if resource == "frame":
|
|
86
|
+
if any(param(k) != "" for k in ("from", "to")):
|
|
87
|
+
return "FrameWindowBody"
|
|
88
|
+
return "FrameBody"
|
|
89
|
+
if resource == "levels":
|
|
90
|
+
return "LevelsBody"
|
|
91
|
+
if resource == "series":
|
|
92
|
+
return "SeriesBody"
|
|
93
|
+
if resource == "surface":
|
|
94
|
+
return "SurfaceBody"
|
|
95
|
+
if resource == "contracts":
|
|
96
|
+
return "ContractsBody"
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def compact_query(body: str) -> str:
|
|
101
|
+
"""The query that asks for `body` in the plugin dialect: the profile, and the layout generation for a shape that needs one."""
|
|
102
|
+
if body in ("ProfileWindowBody", "FrameWindowBody"):
|
|
103
|
+
return "compact=plugin&compact_layout=2"
|
|
104
|
+
return "compact=plugin"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_SCOPED = {"frame": True, "levels": True, "profile": True, "series": True, "contracts": False, "curve_quotes": False, "surface": False}
|
|
108
|
+
_SEGMENT_KEYS = ("scope", "view")
|
|
109
|
+
_REST_ONLY_KEYS = ("at", "anchor", "from", "to", "cursor", "sessions")
|
|
110
|
+
_REST_ONLY_BY_RESOURCE = {"frame": ("step", "max_points"), "series": ("step", "max_points"), "profile": ("step", "max_points", "layout", "min_contracts"), "rollup_vol_baseline": ("session",)}
|
|
111
|
+
_REST_ONLY_EXEMPT = ("rollup_level_timeline.sessions",)
|
|
112
|
+
_LOOKBACK_KEY = "lookback"
|
|
113
|
+
_MAX_LOOKBACK_S = 7200
|
|
114
|
+
_LIST_SEP = "+"
|
|
115
|
+
_MAX_TOPIC_LEN = 1024
|
|
116
|
+
_DEFAULT_SCOPE = "full"
|
|
117
|
+
_NONE_PART = "-"
|
|
118
|
+
_MAX_DECIMAL_DIGITS = 15
|
|
119
|
+
_SAFE = re.compile(r"[A-Za-z0-9._-]+")
|
|
120
|
+
_DECIMAL = re.compile(r"([0-9]+)\.([0-9]+)")
|
|
121
|
+
_LOOKBACK = re.compile(r"-([0-9]+)s")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _rest_only(resource: str, key: str) -> bool:
|
|
125
|
+
if key in _REST_ONLY_KEYS and f"{resource}.{key}" not in _REST_ONLY_EXEMPT:
|
|
126
|
+
return True
|
|
127
|
+
return key in _REST_ONLY_BY_RESOURCE.get(resource, ())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _canonical_value(v: str) -> str | None:
|
|
131
|
+
"""The topic spelling of a REST value: a comma list sorted, deduped and joined, an integer without leading zeros, a short decimal without trailing zeros; None where it cannot ride a topic."""
|
|
132
|
+
if "," in v:
|
|
133
|
+
members = v.split(",")
|
|
134
|
+
if not all(_SAFE.fullmatch(m) for m in members):
|
|
135
|
+
return None
|
|
136
|
+
return _LIST_SEP.join(sorted(set(members)))
|
|
137
|
+
if not _SAFE.fullmatch(v):
|
|
138
|
+
return None
|
|
139
|
+
if _DIGITS.fullmatch(v):
|
|
140
|
+
return v.lstrip("0") or "0"
|
|
141
|
+
d = _DECIMAL.fullmatch(v)
|
|
142
|
+
if d is None:
|
|
143
|
+
return v
|
|
144
|
+
whole = d[1].lstrip("0") or "0"
|
|
145
|
+
frac = d[2].rstrip("0")
|
|
146
|
+
if (0 if whole == "0" else len(whole)) + len(frac) > _MAX_DECIMAL_DIGITS:
|
|
147
|
+
return None
|
|
148
|
+
return whole if frac == "" else f"{whole}.{frac}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _lookback_of(from_value: str | None) -> int | None:
|
|
152
|
+
"""`from=-<N>s` as N seconds within the span a socket holds."""
|
|
153
|
+
m = None if from_value is None else _LOOKBACK.fullmatch(from_value)
|
|
154
|
+
if m is None or len(m[1]) > 9:
|
|
155
|
+
return None
|
|
156
|
+
n = int(m[1])
|
|
157
|
+
return n if 1 <= n <= _MAX_LOOKBACK_S else None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def frame_window_max_points(lookback_s: int, step_ms: int) -> int:
|
|
161
|
+
"""The max_points a frame window of `lookback_s` seconds at `step_ms` asks for."""
|
|
162
|
+
return lookback_s * 1000 // step_ms + FRAME_WINDOW_SLACK
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _render_topic(family: str, ticker: str, scope: str, view: str, params: dict[str, str]) -> str | None:
|
|
166
|
+
topic = f"{family}:{ticker}:{scope}:{view if view else _NONE_PART}"
|
|
167
|
+
if params:
|
|
168
|
+
topic += ":" + ",".join(f"{k}={params[k]}" for k in sorted(params))
|
|
169
|
+
return topic if len(topic) <= _MAX_TOPIC_LEN else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def v2_topic(path: str) -> str | None:
|
|
173
|
+
"""The topic for a `/api/v2/{resource}/{TICKER}?...` read, or None when the read can only be REST; whether the server pushes it is the hello's call."""
|
|
174
|
+
pathname, _, query = path.partition("?")
|
|
175
|
+
if "%" in query or "+" in query or not pathname.startswith("/api/v2/"):
|
|
176
|
+
return None
|
|
177
|
+
parts = pathname[len("/api/v2/") :].split("/")
|
|
178
|
+
if len(parts) != 2:
|
|
179
|
+
return None
|
|
180
|
+
resource, ticker = parts
|
|
181
|
+
scoped = _SCOPED.get(resource)
|
|
182
|
+
if scoped is None or not _SAFE.fullmatch(ticker):
|
|
183
|
+
return None
|
|
184
|
+
q: dict[str, str] = {}
|
|
185
|
+
for pair in query.split("&") if query else []:
|
|
186
|
+
key, _, value = pair.partition("=")
|
|
187
|
+
if key == "" or key in q:
|
|
188
|
+
return None
|
|
189
|
+
q[key] = value
|
|
190
|
+
frame_window = resource == "frame" and "from" in q
|
|
191
|
+
scope, view = q.get("scope", ""), q.get("view", "")
|
|
192
|
+
if ("scope" in q and (not scoped or not _SAFE.fullmatch(scope))) or ("view" in q and not _SAFE.fullmatch(view)):
|
|
193
|
+
return None
|
|
194
|
+
if "plan" in q and (resource != "levels" or q["plan"] != "saved"):
|
|
195
|
+
return None
|
|
196
|
+
params: dict[str, str] = {}
|
|
197
|
+
for k, raw in q.items():
|
|
198
|
+
if k in _SEGMENT_KEYS:
|
|
199
|
+
continue
|
|
200
|
+
if k == _LOOKBACK_KEY:
|
|
201
|
+
return None
|
|
202
|
+
if (resource == "series" or frame_window) and k == "from":
|
|
203
|
+
continue
|
|
204
|
+
if frame_window and k in ("step", "max_points"):
|
|
205
|
+
continue
|
|
206
|
+
if _rest_only(resource, k) or not _SAFE.fullmatch(k):
|
|
207
|
+
return None
|
|
208
|
+
v = _canonical_value(raw)
|
|
209
|
+
if v is None or (resource == "profile" and k == "by" and v == "time"):
|
|
210
|
+
return None
|
|
211
|
+
params[k] = v
|
|
212
|
+
if resource == "series":
|
|
213
|
+
secs = _lookback_of(q.get("from"))
|
|
214
|
+
if secs is None:
|
|
215
|
+
return None
|
|
216
|
+
params[_LOOKBACK_KEY] = str(secs)
|
|
217
|
+
family = resource
|
|
218
|
+
if frame_window:
|
|
219
|
+
secs = _lookback_of(q.get("from"))
|
|
220
|
+
step = q.get("step", "")
|
|
221
|
+
step_ms = FRAME_STEP_MS.get(step)
|
|
222
|
+
if secs is None or step_ms is None or q.get("max_points") != str(frame_window_max_points(secs, step_ms)):
|
|
223
|
+
return None
|
|
224
|
+
params[_LOOKBACK_KEY] = str(secs)
|
|
225
|
+
params["step"] = step
|
|
226
|
+
family = "frame-window"
|
|
227
|
+
return _render_topic(family, ticker, scope or _DEFAULT_SCOPE, view, params)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def profile_tail_topic(ticker: str, scope: str, view: str, lanes: list[str], range_pct: str | None) -> str | None:
|
|
231
|
+
"""The topic that pushes the newest frame of a `profile by=time` window as a one-sample window, in the history read's lanes, scope and strike band (`range_pct`, a fraction of spot; None is the whole ladder); None where a part cannot ride a topic."""
|
|
232
|
+
if not _SAFE.fullmatch(ticker) or not _SAFE.fullmatch(scope) or (view != "" and not _SAFE.fullmatch(view)) or not lanes:
|
|
233
|
+
return None
|
|
234
|
+
lane_list = _canonical_value(",".join(lanes))
|
|
235
|
+
band = None if range_pct is None else _canonical_value(range_pct)
|
|
236
|
+
if lane_list is None or (range_pct is not None and band is None):
|
|
237
|
+
return None
|
|
238
|
+
params = {"lanes": lane_list}
|
|
239
|
+
if band is not None:
|
|
240
|
+
params["range_pct"] = band
|
|
241
|
+
return _render_topic("profile-tail", ticker, scope, view, params)
|