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 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)