itmatrix 1.0.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.
- itmatrix/__init__.py +53 -0
- itmatrix/_core/__init__.py +9 -0
- itmatrix/_core/_wire/__init__.py +1 -0
- itmatrix/_core/_wire/rest_pb2.py +65 -0
- itmatrix/_core/_wire/ws_pb2.py +63 -0
- itmatrix/_core/client.py +587 -0
- itmatrix/_core/config.py +9 -0
- itmatrix/_core/errors.py +31 -0
- itmatrix/_core/models.py +180 -0
- itmatrix/_core/resources.py +633 -0
- itmatrix/_core/stream.py +431 -0
- itmatrix/analytics.py +88 -0
- itmatrix/client.py +485 -0
- itmatrix/models.py +430 -0
- itmatrix/options.py +78 -0
- itmatrix/periods.py +80 -0
- itmatrix/py.typed +0 -0
- itmatrix-1.0.0.dist-info/METADATA +228 -0
- itmatrix-1.0.0.dist-info/RECORD +21 -0
- itmatrix-1.0.0.dist-info/WHEEL +4 -0
- itmatrix-1.0.0.dist-info/licenses/LICENSE +21 -0
itmatrix/_core/client.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""Direct async client for the ITMatrixHQ /v2 API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import warnings
|
|
10
|
+
import weakref
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
from typing import Any, Literal, TypeVar, cast
|
|
14
|
+
from urllib.parse import quote, unquote, urlsplit
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from google.protobuf.message import Message
|
|
18
|
+
from typing_extensions import Self
|
|
19
|
+
|
|
20
|
+
from ._wire.rest_pb2 import BarsResponse, ChainResponse, GexGridResponse, ReplayResponse
|
|
21
|
+
from .config import DEFAULT_BASE_URL
|
|
22
|
+
from .errors import ITMError
|
|
23
|
+
from .models import Credential, GexReferenceData, QueryValue, Result, Transport
|
|
24
|
+
from .resources import (
|
|
25
|
+
AccountResource,
|
|
26
|
+
DarkpoolResource,
|
|
27
|
+
EconomyResource,
|
|
28
|
+
FlowResource,
|
|
29
|
+
FundamentalsResource,
|
|
30
|
+
InfoResource,
|
|
31
|
+
JournalResource,
|
|
32
|
+
MarketResource,
|
|
33
|
+
ProtocolResource,
|
|
34
|
+
ReferenceResource,
|
|
35
|
+
ScreenerResource,
|
|
36
|
+
VolResource,
|
|
37
|
+
WatchlistsResource,
|
|
38
|
+
)
|
|
39
|
+
from .stream import Stream
|
|
40
|
+
|
|
41
|
+
M = TypeVar("M", bound=Message)
|
|
42
|
+
|
|
43
|
+
# The only paths outside /v2/ this client will request. Liveness and readiness
|
|
44
|
+
# are served at the root and are unauthenticated, so InfoResource needs them;
|
|
45
|
+
# everything else stays under /v2/ so a caller cannot wander off the documented
|
|
46
|
+
# surface by passing a path to request(). An allowlist rather than a loosened
|
|
47
|
+
# prefix check: adding a path here should be a deliberate decision.
|
|
48
|
+
_ROOT_PATHS = frozenset({"/healthz", "/readyz"})
|
|
49
|
+
|
|
50
|
+
_CLOSED = "client is closed; create a new client to make more requests"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _warn_unclosed(name: str) -> None:
|
|
54
|
+
warnings.warn(
|
|
55
|
+
f"{name} was garbage-collected without being closed; "
|
|
56
|
+
"await client.aclose() or use `async with`",
|
|
57
|
+
ResourceWarning,
|
|
58
|
+
stacklevel=2,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ITMClient:
|
|
63
|
+
"""One readable entry point; generated protobuf stays behind _wire."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
69
|
+
transport: Transport = "json",
|
|
70
|
+
api_key: Credential | None = None,
|
|
71
|
+
token: Credential | None = None,
|
|
72
|
+
http_client: httpx.AsyncClient | None = None,
|
|
73
|
+
retries: int = 2,
|
|
74
|
+
timeout: float = 30.0,
|
|
75
|
+
http_transport: httpx.AsyncBaseTransport | None = None,
|
|
76
|
+
stream_url: str | None = None,
|
|
77
|
+
stream_connect: Any = None,
|
|
78
|
+
stream_max_queue: int = 64,
|
|
79
|
+
) -> None:
|
|
80
|
+
if transport not in {"json", "protobuf"}:
|
|
81
|
+
raise ValueError(f"unsupported transport {transport!r}")
|
|
82
|
+
if retries < 0:
|
|
83
|
+
raise ValueError("retries must be non-negative")
|
|
84
|
+
if api_key is not None and token is not None:
|
|
85
|
+
raise ValueError("configure api_key or token, not both")
|
|
86
|
+
|
|
87
|
+
parsed = urlsplit(base_url)
|
|
88
|
+
if (
|
|
89
|
+
parsed.scheme not in {"http", "https"}
|
|
90
|
+
or not parsed.netloc
|
|
91
|
+
or parsed.username
|
|
92
|
+
or parsed.password
|
|
93
|
+
or parsed.query
|
|
94
|
+
or parsed.fragment
|
|
95
|
+
or parsed.path not in {"", "/"}
|
|
96
|
+
):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"base_url must be an http(s) origin without credentials, path, query or fragment"
|
|
99
|
+
)
|
|
100
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
101
|
+
raise ValueError("timeout must be finite and positive")
|
|
102
|
+
if http_client is not None and http_transport is not None:
|
|
103
|
+
raise ValueError("configure http_client or http_transport, not both")
|
|
104
|
+
self.base_url = base_url.rstrip("/")
|
|
105
|
+
self.transport = transport
|
|
106
|
+
self._credential = api_key if api_key is not None else token
|
|
107
|
+
self._http = http_client or httpx.AsyncClient(
|
|
108
|
+
timeout=timeout, transport=http_transport, follow_redirects=False
|
|
109
|
+
)
|
|
110
|
+
self._closed = False
|
|
111
|
+
self._owns_http = http_client is None
|
|
112
|
+
self._retries = retries
|
|
113
|
+
# Async cleanup cannot run from a finalizer, so an unclosed client
|
|
114
|
+
# that owns its pool only warns when it is collected.
|
|
115
|
+
self._finalizer = weakref.finalize(self, _warn_unclosed, type(self).__name__)
|
|
116
|
+
self._finalizer.atexit = False
|
|
117
|
+
if not self._owns_http:
|
|
118
|
+
self._finalizer.detach()
|
|
119
|
+
|
|
120
|
+
self.account = AccountResource(self.request)
|
|
121
|
+
self.darkpool = DarkpoolResource(self.request)
|
|
122
|
+
self.offexchange = self.darkpool
|
|
123
|
+
self.economy = EconomyResource(self.request)
|
|
124
|
+
self.flow = FlowResource(self.request)
|
|
125
|
+
self.fundamentals = FundamentalsResource(self.request)
|
|
126
|
+
self.info = InfoResource(self.request)
|
|
127
|
+
self.journal = JournalResource(self.request)
|
|
128
|
+
self.market = MarketResource(self.request)
|
|
129
|
+
self.protocols = ProtocolResource(self.request)
|
|
130
|
+
self.reference = ReferenceResource(self.request)
|
|
131
|
+
self.screener = ScreenerResource(self.request)
|
|
132
|
+
self.vol = VolResource(self.request)
|
|
133
|
+
self.watchlists = WatchlistsResource(self.request)
|
|
134
|
+
|
|
135
|
+
self.stream = Stream(
|
|
136
|
+
url=stream_url or _websocket_url(self.base_url),
|
|
137
|
+
ticket=self._ticket,
|
|
138
|
+
connect=stream_connect,
|
|
139
|
+
max_queue=stream_max_queue,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def __aenter__(self) -> Self:
|
|
143
|
+
if self._closed:
|
|
144
|
+
raise RuntimeError(_CLOSED)
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
148
|
+
await self.aclose()
|
|
149
|
+
|
|
150
|
+
async def aclose(self) -> None:
|
|
151
|
+
"""Close the stream and the owned HTTP pool. Idempotent.
|
|
152
|
+
|
|
153
|
+
``async with`` calls this for you; without it, await ``aclose()``
|
|
154
|
+
when you are done. A request after it raises ``RuntimeError``.
|
|
155
|
+
"""
|
|
156
|
+
if self._closed:
|
|
157
|
+
return
|
|
158
|
+
self._closed = True
|
|
159
|
+
self._finalizer.detach()
|
|
160
|
+
try:
|
|
161
|
+
await self.stream.close()
|
|
162
|
+
finally:
|
|
163
|
+
if self._owns_http:
|
|
164
|
+
await self._http.aclose()
|
|
165
|
+
|
|
166
|
+
async def gex(
|
|
167
|
+
self,
|
|
168
|
+
symbol: str,
|
|
169
|
+
*,
|
|
170
|
+
at: str | int | datetime | None = None,
|
|
171
|
+
tz: str | None = None,
|
|
172
|
+
expiries: str | list[str] | tuple[str, ...] | None = None,
|
|
173
|
+
dte: int | None = None,
|
|
174
|
+
top: int | None = None,
|
|
175
|
+
by_expiry: bool | None = None,
|
|
176
|
+
timeout: float | None = None,
|
|
177
|
+
) -> Result[dict[str, Any] | GexGridResponse]:
|
|
178
|
+
return await self._wire(
|
|
179
|
+
f"/v2/gex/{_symbol(symbol)}/grid",
|
|
180
|
+
{
|
|
181
|
+
"at": at,
|
|
182
|
+
"tz": tz,
|
|
183
|
+
"expiries": expiries,
|
|
184
|
+
"dte": dte,
|
|
185
|
+
"top": top,
|
|
186
|
+
"by_expiry": by_expiry,
|
|
187
|
+
},
|
|
188
|
+
GexGridResponse,
|
|
189
|
+
timeout=timeout,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
async def gex_reference(
|
|
193
|
+
self,
|
|
194
|
+
symbol: str,
|
|
195
|
+
*,
|
|
196
|
+
date: str,
|
|
197
|
+
basis: Literal["open", "prev_close"],
|
|
198
|
+
) -> Result[GexReferenceData]:
|
|
199
|
+
"""Get the exact open or preceding-close persisted GEX book.
|
|
200
|
+
|
|
201
|
+
An unavailable book is a successful response with ``captured_at=None``
|
|
202
|
+
and an empty ``strikes`` list; the server never substitutes a nearby
|
|
203
|
+
session or treats missing strikes as zero.
|
|
204
|
+
"""
|
|
205
|
+
return await self.request(
|
|
206
|
+
f"/v2/gex/{_symbol(symbol)}/reference",
|
|
207
|
+
query={"date": date, "basis": basis},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
async def bars(
|
|
211
|
+
self,
|
|
212
|
+
symbol: str,
|
|
213
|
+
*,
|
|
214
|
+
from_: str | int | datetime,
|
|
215
|
+
to: str | int | datetime,
|
|
216
|
+
timeframe: str | None = None,
|
|
217
|
+
tz: str | None = None,
|
|
218
|
+
source: Literal["trade", "mid"] | None = None,
|
|
219
|
+
limit: int | None = None,
|
|
220
|
+
cursor: str | None = None,
|
|
221
|
+
timeout: float | None = None,
|
|
222
|
+
) -> Result[list[dict[str, Any]] | BarsResponse]:
|
|
223
|
+
return await self._wire(
|
|
224
|
+
f"/v2/stocks/{_symbol(symbol)}/bars",
|
|
225
|
+
{
|
|
226
|
+
"from": from_,
|
|
227
|
+
"to": to,
|
|
228
|
+
"timeframe": timeframe,
|
|
229
|
+
"tz": tz,
|
|
230
|
+
"source": source,
|
|
231
|
+
"limit": limit,
|
|
232
|
+
"cursor": cursor,
|
|
233
|
+
},
|
|
234
|
+
BarsResponse,
|
|
235
|
+
timeout=timeout,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
async def replay(
|
|
239
|
+
self,
|
|
240
|
+
symbol: str,
|
|
241
|
+
*,
|
|
242
|
+
date: str,
|
|
243
|
+
from_: str | None = None,
|
|
244
|
+
to: str | None = None,
|
|
245
|
+
tz: str | None = None,
|
|
246
|
+
timeout: float | None = None,
|
|
247
|
+
) -> Result[dict[str, Any] | ReplayResponse]:
|
|
248
|
+
return await self._wire(
|
|
249
|
+
f"/v2/stocks/{_symbol(symbol)}/replay",
|
|
250
|
+
{"date": date, "from": from_, "to": to, "tz": tz},
|
|
251
|
+
ReplayResponse,
|
|
252
|
+
timeout=timeout,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
async def chain(
|
|
256
|
+
self,
|
|
257
|
+
symbol: str,
|
|
258
|
+
*,
|
|
259
|
+
at: str | int | datetime | None = None,
|
|
260
|
+
tz: str | None = None,
|
|
261
|
+
expiry: str | None = None,
|
|
262
|
+
strike_gte: int | None = None,
|
|
263
|
+
strike_lte: int | None = None,
|
|
264
|
+
timeout: float | None = None,
|
|
265
|
+
) -> Result[dict[str, Any] | ChainResponse]:
|
|
266
|
+
query = _chain_query(at, tz, expiry, strike_gte, strike_lte)
|
|
267
|
+
return await self._wire(
|
|
268
|
+
f"/v2/chain/{_symbol(symbol)}",
|
|
269
|
+
query,
|
|
270
|
+
ChainResponse,
|
|
271
|
+
timeout=timeout,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
async def expirations(
|
|
275
|
+
self,
|
|
276
|
+
symbol: str,
|
|
277
|
+
*,
|
|
278
|
+
at: str | int | datetime | None = None,
|
|
279
|
+
tz: str | None = None,
|
|
280
|
+
expiry: str | None = None,
|
|
281
|
+
strike_gte: int | None = None,
|
|
282
|
+
strike_lte: int | None = None,
|
|
283
|
+
timeout: float | None = None,
|
|
284
|
+
) -> Result[list[str]]:
|
|
285
|
+
return cast(
|
|
286
|
+
Result[list[str]],
|
|
287
|
+
await self._send(
|
|
288
|
+
f"/v2/chain/{_symbol(symbol)}/expirations",
|
|
289
|
+
query=_chain_query(at, tz, expiry, strike_gte, strike_lte),
|
|
290
|
+
force_json=True,
|
|
291
|
+
timeout=timeout,
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
async def request(
|
|
296
|
+
self,
|
|
297
|
+
path: str,
|
|
298
|
+
*,
|
|
299
|
+
method: str = "GET",
|
|
300
|
+
query: Mapping[str, QueryValue] | None = None,
|
|
301
|
+
body: Any = None,
|
|
302
|
+
content_type: str = "application/json",
|
|
303
|
+
accept: str | None = None,
|
|
304
|
+
if_none_match: str | None = None,
|
|
305
|
+
timeout: float | None = None,
|
|
306
|
+
message_type: type[M] | None = None,
|
|
307
|
+
) -> Result[Any]:
|
|
308
|
+
"""Authenticated request to a relative ``/v2/`` path.
|
|
309
|
+
|
|
310
|
+
``timeout`` overrides the client-wide timeout for this call only.
|
|
311
|
+
There is no separate cancellation token: an ``asyncio`` task that is
|
|
312
|
+
cancelled cancels the request with it, which is the language's own
|
|
313
|
+
mechanism and needs no SDK surface.
|
|
314
|
+
"""
|
|
315
|
+
return await self._send(
|
|
316
|
+
path,
|
|
317
|
+
method=method,
|
|
318
|
+
query=query,
|
|
319
|
+
body=body,
|
|
320
|
+
content_type=content_type,
|
|
321
|
+
accept=accept,
|
|
322
|
+
if_none_match=if_none_match,
|
|
323
|
+
message_type=message_type,
|
|
324
|
+
force_json=message_type is None,
|
|
325
|
+
timeout=timeout,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
async def _ticket(self) -> Mapping[str, Any]:
|
|
329
|
+
result = await self._send("/v2/stream/ticket", method="POST", force_json=True)
|
|
330
|
+
return cast(Mapping[str, Any], result.data)
|
|
331
|
+
|
|
332
|
+
async def _wire(
|
|
333
|
+
self,
|
|
334
|
+
path: str,
|
|
335
|
+
query: Mapping[str, QueryValue],
|
|
336
|
+
message_type: type[M],
|
|
337
|
+
timeout: float | None = None,
|
|
338
|
+
) -> Result[Any]:
|
|
339
|
+
return await self._send(
|
|
340
|
+
path, query=query, message_type=message_type, timeout=timeout
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
async def _send(
|
|
344
|
+
self,
|
|
345
|
+
path: str,
|
|
346
|
+
*,
|
|
347
|
+
method: str = "GET",
|
|
348
|
+
query: Mapping[str, QueryValue] | None = None,
|
|
349
|
+
body: Any = None,
|
|
350
|
+
content_type: str = "application/json",
|
|
351
|
+
accept: str | None = None,
|
|
352
|
+
if_none_match: str | None = None,
|
|
353
|
+
message_type: type[M] | None = None,
|
|
354
|
+
force_json: bool = False,
|
|
355
|
+
timeout: float | None = None,
|
|
356
|
+
) -> Result[Any]:
|
|
357
|
+
protobuf = (
|
|
358
|
+
not force_json and self.transport == "protobuf" and message_type is not None
|
|
359
|
+
)
|
|
360
|
+
if self._closed:
|
|
361
|
+
raise RuntimeError(_CLOSED)
|
|
362
|
+
decoded = unquote(path)
|
|
363
|
+
parsed = urlsplit(path)
|
|
364
|
+
if (
|
|
365
|
+
(not path.startswith("/v2/") and path not in _ROOT_PATHS)
|
|
366
|
+
or parsed.scheme
|
|
367
|
+
or parsed.netloc
|
|
368
|
+
or parsed.query
|
|
369
|
+
or parsed.fragment
|
|
370
|
+
or "\\" in decoded
|
|
371
|
+
or any(p in {".", ".."} for p in decoded.split("/"))
|
|
372
|
+
):
|
|
373
|
+
raise ValueError(
|
|
374
|
+
"path must be a relative /v2/ API path without traversal, query or fragment"
|
|
375
|
+
)
|
|
376
|
+
method = method.upper()
|
|
377
|
+
url = self.base_url + path
|
|
378
|
+
params = {
|
|
379
|
+
key: _query_value(value)
|
|
380
|
+
for key, value in (query or {}).items()
|
|
381
|
+
if value is not None
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for attempt in range(self._retries + 1):
|
|
385
|
+
headers = {
|
|
386
|
+
"Accept": accept
|
|
387
|
+
or ("application/x-protobuf" if protobuf else "application/json")
|
|
388
|
+
}
|
|
389
|
+
credential = await _credential(self._credential)
|
|
390
|
+
if credential:
|
|
391
|
+
headers["Authorization"] = f"Bearer {credential}"
|
|
392
|
+
if if_none_match:
|
|
393
|
+
headers["If-None-Match"] = if_none_match
|
|
394
|
+
response = await _request(
|
|
395
|
+
self._http,
|
|
396
|
+
method,
|
|
397
|
+
url,
|
|
398
|
+
params=params,
|
|
399
|
+
headers=headers,
|
|
400
|
+
body=body,
|
|
401
|
+
content_type=content_type,
|
|
402
|
+
timeout=timeout,
|
|
403
|
+
)
|
|
404
|
+
if (
|
|
405
|
+
method == "GET"
|
|
406
|
+
and attempt < self._retries
|
|
407
|
+
and response.status_code in {429, 502, 503, 504}
|
|
408
|
+
):
|
|
409
|
+
retry_after = _number(response.headers.get("retry-after"))
|
|
410
|
+
await asyncio.sleep(
|
|
411
|
+
min(retry_after, 30.0)
|
|
412
|
+
if retry_after and math.isfinite(retry_after) and retry_after > 0
|
|
413
|
+
else min(0.25 * (2**attempt), 2.0)
|
|
414
|
+
)
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
request_id = response.headers.get("x-request-id")
|
|
418
|
+
etag = response.headers.get("etag")
|
|
419
|
+
if response.status_code == 304:
|
|
420
|
+
return Result(None, {}, 304, request_id, etag, True)
|
|
421
|
+
if not response.is_success:
|
|
422
|
+
raise _response_error(response)
|
|
423
|
+
|
|
424
|
+
response_type = response.headers.get("content-type", "").lower()
|
|
425
|
+
if response_type.startswith("application/x-protobuf"):
|
|
426
|
+
if message_type is None:
|
|
427
|
+
raise TypeError("protobuf response received without a decoder")
|
|
428
|
+
return Result(
|
|
429
|
+
message_type.FromString(response.content),
|
|
430
|
+
_binary_meta(response.headers),
|
|
431
|
+
response.status_code,
|
|
432
|
+
request_id,
|
|
433
|
+
etag,
|
|
434
|
+
)
|
|
435
|
+
if "json" not in response_type:
|
|
436
|
+
return Result(
|
|
437
|
+
response.text,
|
|
438
|
+
{},
|
|
439
|
+
response.status_code,
|
|
440
|
+
request_id,
|
|
441
|
+
etag,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
value = response.json()
|
|
445
|
+
if isinstance(value, dict) and "data" in value:
|
|
446
|
+
meta = value.get("meta")
|
|
447
|
+
return Result(
|
|
448
|
+
value["data"],
|
|
449
|
+
meta if isinstance(meta, dict) else {},
|
|
450
|
+
response.status_code,
|
|
451
|
+
request_id,
|
|
452
|
+
etag,
|
|
453
|
+
)
|
|
454
|
+
return Result(value, {}, response.status_code, request_id, etag)
|
|
455
|
+
raise AssertionError("unreachable")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async def _request(
|
|
459
|
+
client: httpx.AsyncClient,
|
|
460
|
+
method: str,
|
|
461
|
+
url: str,
|
|
462
|
+
*,
|
|
463
|
+
params: Mapping[str, str | int | float],
|
|
464
|
+
headers: Mapping[str, str],
|
|
465
|
+
body: Any,
|
|
466
|
+
content_type: str,
|
|
467
|
+
timeout: float | None = None,
|
|
468
|
+
) -> httpx.Response:
|
|
469
|
+
# httpx treats an omitted `timeout` as "use the client's"; passing one
|
|
470
|
+
# overrides it for this call only. Never widened silently — a caller who
|
|
471
|
+
# wants longer than the client allows says so here.
|
|
472
|
+
extra: dict[str, Any] = {} if timeout is None else {"timeout": timeout}
|
|
473
|
+
if body is None:
|
|
474
|
+
return await client.request(
|
|
475
|
+
method, url, params=params, headers=headers, follow_redirects=False, **extra
|
|
476
|
+
)
|
|
477
|
+
if content_type != "application/json":
|
|
478
|
+
if not isinstance(body, str | bytes):
|
|
479
|
+
raise TypeError("non-JSON request bodies must be strings or bytes")
|
|
480
|
+
return await client.request(
|
|
481
|
+
method,
|
|
482
|
+
url,
|
|
483
|
+
params=params,
|
|
484
|
+
headers={**headers, "Content-Type": content_type},
|
|
485
|
+
content=body,
|
|
486
|
+
follow_redirects=False,
|
|
487
|
+
**extra,
|
|
488
|
+
)
|
|
489
|
+
return await client.request(
|
|
490
|
+
method,
|
|
491
|
+
url,
|
|
492
|
+
params=params,
|
|
493
|
+
headers=headers,
|
|
494
|
+
json=body,
|
|
495
|
+
follow_redirects=False,
|
|
496
|
+
**extra,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _chain_query(
|
|
501
|
+
at: str | int | datetime | None,
|
|
502
|
+
tz: str | None,
|
|
503
|
+
expiry: str | None,
|
|
504
|
+
strike_gte: int | None,
|
|
505
|
+
strike_lte: int | None,
|
|
506
|
+
) -> Mapping[str, QueryValue]:
|
|
507
|
+
return {
|
|
508
|
+
"at": at,
|
|
509
|
+
"tz": tz,
|
|
510
|
+
"expiry": expiry,
|
|
511
|
+
"strike_gte": strike_gte,
|
|
512
|
+
"strike_lte": strike_lte,
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
async def _credential(value: Credential | None) -> str | None:
|
|
517
|
+
if value is None:
|
|
518
|
+
return None
|
|
519
|
+
result = value() if callable(value) else value
|
|
520
|
+
return await result if inspect.isawaitable(result) else result
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _query_value(value: QueryValue) -> str | int | float:
|
|
524
|
+
if isinstance(value, bool):
|
|
525
|
+
return str(value).lower()
|
|
526
|
+
if isinstance(value, datetime | date):
|
|
527
|
+
return value.isoformat()
|
|
528
|
+
if isinstance(value, str | int | float):
|
|
529
|
+
return value
|
|
530
|
+
return ",".join(value)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _response_error(response: httpx.Response) -> ITMError:
|
|
534
|
+
try:
|
|
535
|
+
value = response.json()
|
|
536
|
+
except ValueError:
|
|
537
|
+
value = {}
|
|
538
|
+
error = value.get("error", {}) if isinstance(value, dict) else {}
|
|
539
|
+
if not isinstance(error, dict):
|
|
540
|
+
error = {}
|
|
541
|
+
details = error.get("details")
|
|
542
|
+
return ITMError(
|
|
543
|
+
status=response.status_code,
|
|
544
|
+
code=str(error.get("code", "unknown")),
|
|
545
|
+
message=str(error.get("message", response.reason_phrase or "request failed")),
|
|
546
|
+
details=details if isinstance(details, dict) else None,
|
|
547
|
+
request_id=response.headers.get("x-request-id"),
|
|
548
|
+
retry_after_seconds=_number(response.headers.get("retry-after")),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _binary_meta(headers: httpx.Headers) -> Mapping[str, Any]:
|
|
553
|
+
extra = _header_json(headers.get("x-itm-extra"))
|
|
554
|
+
meta = dict(extra) if isinstance(extra, dict) else {}
|
|
555
|
+
plane = headers.get("x-itm-plane")
|
|
556
|
+
caps = _header_json(headers.get("x-itm-caps"))
|
|
557
|
+
if plane is not None:
|
|
558
|
+
meta["plane"] = plane
|
|
559
|
+
if caps is not None:
|
|
560
|
+
meta["caps"] = caps
|
|
561
|
+
return meta
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _header_json(value: str | None) -> Any:
|
|
565
|
+
try:
|
|
566
|
+
return json.loads(value) if value is not None else None
|
|
567
|
+
except json.JSONDecodeError:
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _number(value: str | None) -> float | None:
|
|
572
|
+
try:
|
|
573
|
+
return float(value) if value is not None else None
|
|
574
|
+
except ValueError:
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _symbol(value: str) -> str:
|
|
579
|
+
return quote(value.upper(), safe="")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _websocket_url(base_url: str) -> str:
|
|
583
|
+
if base_url.startswith("https://"):
|
|
584
|
+
return "wss://" + base_url.removeprefix("https://") + "/v2/ws"
|
|
585
|
+
if base_url.startswith("http://"):
|
|
586
|
+
return "ws://" + base_url.removeprefix("http://") + "/v2/ws"
|
|
587
|
+
raise ValueError("base_url must use http or https")
|
itmatrix/_core/config.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Client defaults, defined once for the Python package.
|
|
2
|
+
|
|
3
|
+
``DEFAULT_BASE_URL`` is the only default API origin in the Python package.
|
|
4
|
+
Pass ``base_url=`` to use another origin.
|
|
5
|
+
Once the pinned OpenAPI document declares ``servers``, this value must equal
|
|
6
|
+
``servers[0].url``; the repository's public-boundary check enforces that.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.itmatrixhq.com"
|
itmatrix/_core/errors.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Stable SDK errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ITMError(Exception):
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
*,
|
|
13
|
+
status: int,
|
|
14
|
+
code: str,
|
|
15
|
+
message: str,
|
|
16
|
+
details: Mapping[str, Any] | None = None,
|
|
17
|
+
request_id: str | None = None,
|
|
18
|
+
retry_after_seconds: float | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status = status
|
|
22
|
+
self.code = code
|
|
23
|
+
# Assigned explicitly: `Exception` keeps the text only in `args`, so
|
|
24
|
+
# without this `e.message` raises AttributeError while every sibling
|
|
25
|
+
# field resolves — and `message` is the one an error handler reaches
|
|
26
|
+
# for first. TypeScript's ITMError exposes it, so this also keeps the
|
|
27
|
+
# two languages saying the same thing.
|
|
28
|
+
self.message = message
|
|
29
|
+
self.details = details
|
|
30
|
+
self.request_id = request_id
|
|
31
|
+
self.retry_after_seconds = retry_after_seconds
|