vexity 0.1.0__tar.gz
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-0.1.0/.gitignore +6 -0
- vexity-0.1.0/LICENSE +25 -0
- vexity-0.1.0/PKG-INFO +79 -0
- vexity-0.1.0/README.md +40 -0
- vexity-0.1.0/pyproject.toml +49 -0
- vexity-0.1.0/src/vexity/__init__.py +41 -0
- vexity-0.1.0/src/vexity/client.py +259 -0
- vexity-0.1.0/src/vexity/client_contract_gen.py +241 -0
- vexity-0.1.0/src/vexity/client_policy_gen.py +319 -0
- vexity-0.1.0/src/vexity/compact/__init__.py +0 -0
- vexity-0.1.0/src/vexity/compact/append.py +126 -0
- vexity-0.1.0/src/vexity/compact/append_gen.py +31 -0
- vexity-0.1.0/src/vexity/compact/block.py +198 -0
- vexity-0.1.0/src/vexity/compact/decode.py +328 -0
- vexity-0.1.0/src/vexity/compact/layout_gen.py +879 -0
- vexity-0.1.0/src/vexity/errors.py +72 -0
- vexity-0.1.0/src/vexity/etag_store.py +108 -0
- vexity-0.1.0/src/vexity/live/__init__.py +5 -0
- vexity-0.1.0/src/vexity/live/session.py +332 -0
- vexity-0.1.0/src/vexity/live/socket.py +82 -0
- vexity-0.1.0/src/vexity/pacing.py +49 -0
- vexity-0.1.0/src/vexity/py.typed +0 -0
- vexity-0.1.0/src/vexity/windows.py +161 -0
vexity-0.1.0/.gitignore
ADDED
vexity-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vexity
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
The MIT licence covers the code of this package only. Use of the Vexity API and
|
|
24
|
+
of the data it returns is governed by the Vexity Terms and Conditions
|
|
25
|
+
(https://vexity.net/terms) and the Redistribution Policy (https://vexity.net/redistribution).
|
vexity-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vexity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the Vexity options-analytics API: ETag-aware reads, incremental windows and the live socket.
|
|
5
|
+
Author-email: Vexity <vexity@jzn-management.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Vexity
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
The MIT licence covers the code of this package only. Use of the Vexity API and
|
|
29
|
+
of the data it returns is governed by the Vexity Terms and Conditions
|
|
30
|
+
(https://vexity.net/terms) and the Redistribution Policy (https://vexity.net/redistribution).
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: api-client,gamma-exposure,options,vexity
|
|
33
|
+
Requires-Python: >=3.11
|
|
34
|
+
Requires-Dist: httpx>=0.27
|
|
35
|
+
Requires-Dist: msgpack>=1.0
|
|
36
|
+
Requires-Dist: websockets>=13
|
|
37
|
+
Requires-Dist: zstandard>=0.23; python_version < '3.14'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# vexity
|
|
41
|
+
|
|
42
|
+
Official Python client for the [Vexity](https://vexity.net) options-analytics API.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import vexity
|
|
46
|
+
|
|
47
|
+
client = vexity.Vexity() # reads VEXITY_API_KEY
|
|
48
|
+
levels = client.levels("SPX", scope="full") # the JSON body, as a dict
|
|
49
|
+
|
|
50
|
+
window = client.window("series", "SPX", names="spot,net_gex_oi", scope="latest", step="1m", span="2h")
|
|
51
|
+
update = window.poll() # the first call reads the window; later calls read only the tail and merge it
|
|
52
|
+
|
|
53
|
+
with client.live() as live: # zstd socket, redials by the server's own client policy
|
|
54
|
+
live.subscribe("levels", "SPX", scope="full", every_ms=5000)
|
|
55
|
+
for event in live:
|
|
56
|
+
print(event)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
What it does for you, with no flags:
|
|
60
|
+
|
|
61
|
+
- Sends `If-None-Match` and stores answers. A 304 saves the body, not the request, and happens only on reads that move slowly (contracts, surface, series envelopes, a window between chain cycles): live levels, frame and grid bodies change every second, so poll them rarely or use the socket. A closed window (`immutable`) is served from the store with no request, and `Vexity(store=EtagStore(directory=...))` keeps it across runs.
|
|
62
|
+
- `Window` reads a live window once in full (following `next_cursor`), then asks `from=<the step boundary at or before the newest held timestamp>` (the newest timestamp itself on an aligned series, so the first returned bucket is a whole one) and merges by the documented rule: drop the held points at or after the answer's first timestamp, append the answer. A step of a session day is read in full each poll. A frame read with neither `from` nor `to` is a single frame, not a window. A tail is about 1 % of a full re-read; when `resolution_ms`, `projection_id`, `compute_version`, the view or the lanes change, the window is read again in full.
|
|
63
|
+
- Waits `Retry-After` (seconds or HTTP date) and paces itself when the key's budget reaches zero; a wait longer than `max_wait_s` raises `RateLimited` instead of hanging.
|
|
64
|
+
- Raises typed errors (`AuthError`, `PlanError`, `Forbidden`, `NotFound`, `RateLimited`, `BadRequest`, `ServerError`, `TransportError`), each carrying the server's `reason`, `detail` and `request_id`.
|
|
65
|
+
- Live sockets use zstd framing, take `every_ms` as the server's rung, answer a `gap` by waiting for the next snapshot, and redial on the generated backoff; a `Reconnected` event says to read REST once for the span it was down.
|
|
66
|
+
|
|
67
|
+
Instant reads and sockets ride compact protobuf by default, at compact layout 2 (`compact=plugin&compact_layout=2&layout_id=<id>`; on sockets the `compact.layout.2` feature), the layout that carries every member, the freshness pairs (`book_stale`, `basis_stale`, ...) included. A decoded body equals its JSON twin; a member the JSON omits because it does not apply reads `None`. Windows (`Window`) follow the same default. `Vexity(compact=False)`, or `compact=False` on one call, reads JSON instead.
|
|
68
|
+
|
|
69
|
+
What the pin does and does not promise: layout 2 is the newest layout this release of `vexity` was generated from (the pin moves with the release, not with the server). The server checks the layout id this release sends and answers 409 when a required member changed, and the SDK checks the layout the server served (the ETag's `-cpb2`) and the layout a socket negotiated; each failure raises `LayoutMismatch` and tells you to upgrade `vexity` or pass `compact=False`. Layout 2 can still grow in place with optional members: a member this release does not know is skipped with an `UnknownMembersWarning` naming it, so upgrade when you see one, or read JSON. The warning is raised once per member and process. A server release that adds a member keeps the previous layout id accepted (the `register` job refuses a release that drops it), so an older SDK keeps working and only warns.
|
|
70
|
+
|
|
71
|
+
The live socket takes the key in its URL, as the API requires, so keep the `websockets` logger above DEBUG. The key is read from `VEXITY_API_KEY` or `api_key=`; it is masked in `repr` and never logged.
|
|
72
|
+
|
|
73
|
+
## User-Agent
|
|
74
|
+
|
|
75
|
+
Requests and the socket send `vexity-py/<version>`. Pass `Vexity(app="myapp/1.2")` to append your own name (`vexity-py/0.1.0 myapp/1.2`) so Vexity can reach heavy users.
|
|
76
|
+
|
|
77
|
+
## Licence and use of the data
|
|
78
|
+
|
|
79
|
+
The code of this package is MIT licensed. Use of the Vexity API and of the data it returns is governed by the [Vexity Terms and Conditions](https://vexity.net/terms) and the [Redistribution Policy](https://vexity.net/redistribution), not by the MIT licence. In particular the data may be cached transiently, not accumulated into datasets of your own. `EtagStore(directory=...)` is an opt-in revalidation cache: it keeps closed answers only until their `max-age` lapses so a re-run does not fetch them again, and it is not an archive. Entries are deleted when their `max-age` lapses, so a read after that downloads the body again instead of revalidating. Questions: vexity@jzn-management.com.
|
vexity-0.1.0/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# vexity
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Vexity](https://vexity.net) options-analytics API.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import vexity
|
|
7
|
+
|
|
8
|
+
client = vexity.Vexity() # reads VEXITY_API_KEY
|
|
9
|
+
levels = client.levels("SPX", scope="full") # the JSON body, as a dict
|
|
10
|
+
|
|
11
|
+
window = client.window("series", "SPX", names="spot,net_gex_oi", scope="latest", step="1m", span="2h")
|
|
12
|
+
update = window.poll() # the first call reads the window; later calls read only the tail and merge it
|
|
13
|
+
|
|
14
|
+
with client.live() as live: # zstd socket, redials by the server's own client policy
|
|
15
|
+
live.subscribe("levels", "SPX", scope="full", every_ms=5000)
|
|
16
|
+
for event in live:
|
|
17
|
+
print(event)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
What it does for you, with no flags:
|
|
21
|
+
|
|
22
|
+
- Sends `If-None-Match` and stores answers. A 304 saves the body, not the request, and happens only on reads that move slowly (contracts, surface, series envelopes, a window between chain cycles): live levels, frame and grid bodies change every second, so poll them rarely or use the socket. A closed window (`immutable`) is served from the store with no request, and `Vexity(store=EtagStore(directory=...))` keeps it across runs.
|
|
23
|
+
- `Window` reads a live window once in full (following `next_cursor`), then asks `from=<the step boundary at or before the newest held timestamp>` (the newest timestamp itself on an aligned series, so the first returned bucket is a whole one) and merges by the documented rule: drop the held points at or after the answer's first timestamp, append the answer. A step of a session day is read in full each poll. A frame read with neither `from` nor `to` is a single frame, not a window. A tail is about 1 % of a full re-read; when `resolution_ms`, `projection_id`, `compute_version`, the view or the lanes change, the window is read again in full.
|
|
24
|
+
- Waits `Retry-After` (seconds or HTTP date) and paces itself when the key's budget reaches zero; a wait longer than `max_wait_s` raises `RateLimited` instead of hanging.
|
|
25
|
+
- Raises typed errors (`AuthError`, `PlanError`, `Forbidden`, `NotFound`, `RateLimited`, `BadRequest`, `ServerError`, `TransportError`), each carrying the server's `reason`, `detail` and `request_id`.
|
|
26
|
+
- Live sockets use zstd framing, take `every_ms` as the server's rung, answer a `gap` by waiting for the next snapshot, and redial on the generated backoff; a `Reconnected` event says to read REST once for the span it was down.
|
|
27
|
+
|
|
28
|
+
Instant reads and sockets ride compact protobuf by default, at compact layout 2 (`compact=plugin&compact_layout=2&layout_id=<id>`; on sockets the `compact.layout.2` feature), the layout that carries every member, the freshness pairs (`book_stale`, `basis_stale`, ...) included. A decoded body equals its JSON twin; a member the JSON omits because it does not apply reads `None`. Windows (`Window`) follow the same default. `Vexity(compact=False)`, or `compact=False` on one call, reads JSON instead.
|
|
29
|
+
|
|
30
|
+
What the pin does and does not promise: layout 2 is the newest layout this release of `vexity` was generated from (the pin moves with the release, not with the server). The server checks the layout id this release sends and answers 409 when a required member changed, and the SDK checks the layout the server served (the ETag's `-cpb2`) and the layout a socket negotiated; each failure raises `LayoutMismatch` and tells you to upgrade `vexity` or pass `compact=False`. Layout 2 can still grow in place with optional members: a member this release does not know is skipped with an `UnknownMembersWarning` naming it, so upgrade when you see one, or read JSON. The warning is raised once per member and process. A server release that adds a member keeps the previous layout id accepted (the `register` job refuses a release that drops it), so an older SDK keeps working and only warns.
|
|
31
|
+
|
|
32
|
+
The live socket takes the key in its URL, as the API requires, so keep the `websockets` logger above DEBUG. The key is read from `VEXITY_API_KEY` or `api_key=`; it is masked in `repr` and never logged.
|
|
33
|
+
|
|
34
|
+
## User-Agent
|
|
35
|
+
|
|
36
|
+
Requests and the socket send `vexity-py/<version>`. Pass `Vexity(app="myapp/1.2")` to append your own name (`vexity-py/0.1.0 myapp/1.2`) so Vexity can reach heavy users.
|
|
37
|
+
|
|
38
|
+
## Licence and use of the data
|
|
39
|
+
|
|
40
|
+
The code of this package is MIT licensed. Use of the Vexity API and of the data it returns is governed by the [Vexity Terms and Conditions](https://vexity.net/terms) and the [Redistribution Policy](https://vexity.net/redistribution), not by the MIT licence. In particular the data may be cached transiently, not accumulated into datasets of your own. `EtagStore(directory=...)` is an opt-in revalidation cache: it keeps closed answers only until their `max-age` lapses so a re-run does not fetch them again, and it is not an archive. Entries are deleted when their `max-age` lapses, so a read after that downloads the body again instead of revalidating. Questions: vexity@jzn-management.com.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vexity"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Official Python client for the Vexity options-analytics API: ETag-aware reads, incremental windows and the live socket."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { file = "LICENSE" }
|
|
8
|
+
authors = [{ name = "Vexity", email = "vexity@jzn-management.com" }]
|
|
9
|
+
keywords = ["vexity", "options", "gamma-exposure", "api-client"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.27",
|
|
12
|
+
"msgpack>=1.0",
|
|
13
|
+
"websockets>=13",
|
|
14
|
+
"zstandard>=0.23; python_version < '3.14'",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["hatchling"]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.sdist]
|
|
22
|
+
include = ["src/vexity", "README.md", "LICENSE", "pyproject.toml"]
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/vexity"]
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest>=8"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
pythonpath = ["src"]
|
|
33
|
+
addopts = "-q"
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
target-version = "py311"
|
|
37
|
+
line-length = 270
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF", "N"]
|
|
41
|
+
ignore = ["E501", "N818"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint.isort]
|
|
44
|
+
known-first-party = ["vexity"]
|
|
45
|
+
combine-as-imports = true
|
|
46
|
+
|
|
47
|
+
[tool.ruff.format]
|
|
48
|
+
quote-style = "double"
|
|
49
|
+
line-ending = "lf"
|
|
@@ -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
|
+
]
|
|
@@ -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"]
|