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.
@@ -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")
@@ -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"
@@ -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