stx-python 0.6.0rc1__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.
stx/_async_client.py ADDED
@@ -0,0 +1,923 @@
1
+ """``AsyncSTX``: the asyncio REST client.
2
+
3
+ Every method maps to one REST route under ``/api/v1`` and returns pydantic
4
+ models from :mod:`stx.models`. Money and quantities are strings exactly as
5
+ the API sends them (``"0.5600"``, ``"2.00"``).
6
+
7
+ Transport is ``httpx``: one library serves this client and the sync
8
+ :class:`stx.STX` wrapper, and its ``MockTransport`` lets the unit tests
9
+ check each request byte for byte without a server.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ import time
17
+ from decimal import Decimal
18
+ from typing import (
19
+ TYPE_CHECKING,
20
+ Any,
21
+ AsyncIterator,
22
+ Awaitable,
23
+ Callable,
24
+ Dict,
25
+ List,
26
+ Mapping,
27
+ Optional,
28
+ Sequence,
29
+ Type,
30
+ TypeVar,
31
+ Union,
32
+ )
33
+
34
+ import httpx
35
+
36
+ from stx import models
37
+ from stx._config import base_url_for, socket_url_for
38
+ from stx._http import PreparedRequest, build_request, decode_response, split_page
39
+ from stx._operations import OPERATIONS
40
+ from stx._paging import Page
41
+ from stx._results import BatchOrderResult
42
+ from stx._retry import RetryPolicy
43
+ from stx._settings import _SENTINEL as _UNSET
44
+ from stx._settings import resolve_settings
45
+ from stx._signing import ApiKeyCredentials, Signer, credentials_from_settings
46
+ from stx._version import USER_AGENT
47
+ from stx.exceptions import (
48
+ STXConfigException,
49
+ STXException,
50
+ STXNotFoundException,
51
+ STXTransportException,
52
+ )
53
+
54
+ if TYPE_CHECKING:
55
+ from stx._ws import STXWebSocket
56
+
57
+ logger = logging.getLogger("stx")
58
+
59
+ T = TypeVar("T")
60
+
61
+ StrList = Union[str, Sequence[str], None]
62
+ Amount = Union[str, Decimal]
63
+
64
+ # Fields POST /api/v1/orders accepts, from the NewOrder schema.
65
+ _NEW_ORDER_FIELDS = frozenset(models.NewOrder.model_fields)
66
+
67
+
68
+ def _amount(name: str, value: Any, *, required: bool) -> Optional[str]:
69
+ # Price and quantity go on the wire as strings. A float cannot carry an
70
+ # exact decimal, and the API refuses JSON numbers for both fields, so
71
+ # only str and Decimal are accepted here.
72
+ if value is None:
73
+ if required:
74
+ raise ValueError(f"{name} is required")
75
+ return None
76
+ if isinstance(value, bool) or not isinstance(value, (str, Decimal)):
77
+ raise TypeError(
78
+ f"{name} must be a decimal string such as {'0.56' if name == 'price' else '2'!r}, "
79
+ f"got {type(value).__name__}"
80
+ )
81
+ return str(value)
82
+
83
+
84
+ def order_body(
85
+ market_id: str,
86
+ action: str,
87
+ order_type: str,
88
+ *,
89
+ price: Optional[Amount] = None,
90
+ quantity: Amount,
91
+ client_order_id: Optional[str] = None,
92
+ expiration: Optional[str] = None,
93
+ expiration_time: Optional[int] = None,
94
+ cancel_on_disconnect: Optional[bool] = None,
95
+ device_id: Optional[str] = None,
96
+ ) -> Dict[str, Any]:
97
+ """The JSON body for one new order. Fields left ``None`` are omitted."""
98
+ body: Dict[str, Any] = {
99
+ "market_id": market_id,
100
+ "action": action,
101
+ "order_type": order_type,
102
+ "quantity": _amount("quantity", quantity, required=True),
103
+ }
104
+ optional = {
105
+ "price": _amount("price", price, required=False),
106
+ "client_order_id": client_order_id,
107
+ "expiration": expiration,
108
+ "expiration_time": expiration_time,
109
+ "cancel_on_disconnect": cancel_on_disconnect,
110
+ "device_id": device_id,
111
+ }
112
+ body.update({k: v for k, v in optional.items() if v is not None})
113
+ return body
114
+
115
+
116
+ def _normalize_order(order: Mapping[str, Any]) -> Dict[str, Any]:
117
+ unknown = set(order) - _NEW_ORDER_FIELDS
118
+ if unknown:
119
+ raise ValueError(
120
+ f"Unknown order fields {sorted(unknown)}; expected {sorted(_NEW_ORDER_FIELDS)}"
121
+ )
122
+ for key in ("market_id", "action", "order_type"):
123
+ if not order.get(key):
124
+ raise ValueError(f"{key} is required on every order")
125
+ fields = {k: v for k, v in order.items() if k not in ("market_id", "action", "order_type")}
126
+ return order_body(order["market_id"], order["action"], order["order_type"], **fields)
127
+
128
+
129
+ def _ids(value: StrList) -> Optional[List[str]]:
130
+ if value is None:
131
+ return None
132
+ if isinstance(value, str):
133
+ return [value]
134
+ return list(value)
135
+
136
+
137
+ class AsyncSTX:
138
+ """Async client for the STX exchange.
139
+
140
+ Configuration comes from the arguments, then environment variables,
141
+ then a profile in ``~/.stx/credentials`` (see ``stx._settings``)::
142
+
143
+ async with AsyncSTX(profile="us-demo") as client:
144
+ me = await client.me()
145
+ page = await client.markets(status="open", limit=10)
146
+
147
+ Args:
148
+ region: with ``env``, picks a known host, e.g. ``region="us", env="demo"``.
149
+ env: the environment within ``region``, e.g. ``"demo"`` or ``"production"``.
150
+ host: a hostname or URL, overriding ``region``/``env``.
151
+ key_id: the API key id.
152
+ private_key: the key's Ed25519 private key (PEM text or a path to a
153
+ PEM file).
154
+ signer: instead of ``private_key``, a callable ``bytes -> bytes``
155
+ returning the raw Ed25519 signature (for keys in an HSM or KMS).
156
+ profile: section of ``~/.stx/credentials`` to read.
157
+ verify_tls: set ``False`` only for a local server.
158
+ retry: a :class:`stx.RetryPolicy`; ``stx.NO_RETRY`` disables retries.
159
+ timeout: seconds per HTTP request.
160
+ transport: an ``httpx.AsyncBaseTransport``, for tests.
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ *,
166
+ region: Any = _UNSET,
167
+ env: Any = _UNSET,
168
+ host: Any = _UNSET,
169
+ key_id: Any = _UNSET,
170
+ private_key: Any = _UNSET,
171
+ signer: Optional[Signer] = None,
172
+ profile: Any = _UNSET,
173
+ verify_tls: Any = _UNSET,
174
+ retry: Optional[RetryPolicy] = None,
175
+ timeout: float = 30.0,
176
+ transport: Optional[httpx.AsyncBaseTransport] = None,
177
+ ) -> None:
178
+ settings = resolve_settings(
179
+ region=region,
180
+ env=env,
181
+ host=host,
182
+ key_id=key_id,
183
+ private_key=private_key,
184
+ verify_tls=verify_tls,
185
+ profile=profile,
186
+ )
187
+ self.base_url = base_url_for(settings.region, settings.env, settings.host)
188
+ self.socket_url = socket_url_for(self.base_url)
189
+ self.profile = settings.profile_name
190
+ # A signer replaces key material; a key from the environment or a
191
+ # profile is ignored then, and only an explicit private_key conflicts.
192
+ key_material = settings.private_key
193
+ if signer is not None and private_key is _UNSET:
194
+ key_material = None
195
+ self.credentials: Optional[ApiKeyCredentials] = credentials_from_settings(
196
+ settings.key_id, key_material, signer
197
+ )
198
+ self.verify_tls = settings.verify_tls
199
+ self.retry = retry if retry is not None else RetryPolicy()
200
+ self.timeout = timeout
201
+ self._transport = transport
202
+ self._http: Optional[httpx.AsyncClient] = None
203
+ self._me: Optional[models.Me] = None
204
+ self._sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
205
+
206
+ def __repr__(self) -> str:
207
+ key = self.credentials.key_id if self.credentials else None
208
+ return f"AsyncSTX(base_url={self.base_url!r}, key_id={key!r})"
209
+
210
+ # ------------------------------------------------------------------
211
+ # Lifecycle
212
+ # ------------------------------------------------------------------
213
+
214
+ async def __aenter__(self) -> AsyncSTX:
215
+ return self
216
+
217
+ async def __aexit__(self, *exc: Any) -> None:
218
+ await self.close()
219
+
220
+ async def close(self) -> None:
221
+ """Close the HTTP connection pool. Safe to call twice."""
222
+ if self._http is not None:
223
+ await self._http.aclose()
224
+ self._http = None
225
+
226
+ def _client(self) -> httpx.AsyncClient:
227
+ if self._http is None:
228
+ self._http = httpx.AsyncClient(
229
+ base_url=self.base_url,
230
+ timeout=self.timeout,
231
+ verify=self.verify_tls,
232
+ transport=self._transport,
233
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
234
+ )
235
+ return self._http
236
+
237
+ # ------------------------------------------------------------------
238
+ # Transport
239
+ # ------------------------------------------------------------------
240
+
241
+ def _headers(self, request: PreparedRequest) -> Dict[str, str]:
242
+ if self.credentials is None:
243
+ raise STXConfigException(
244
+ "No API key configured. Pass key_id= and private_key= (or set "
245
+ "STX_KEY_ID and STX_PRIVATE_KEY, or use a profile in "
246
+ "~/.stx/credentials). Every request is signed."
247
+ )
248
+ headers = self.credentials.headers(request.method, request.path)
249
+ if request.body is not None:
250
+ headers["Content-Type"] = "application/json"
251
+ return headers
252
+
253
+ async def _send_once(self, request: PreparedRequest) -> Dict[str, Any]:
254
+ # A fresh signature per attempt: the timestamp must be within 30 s
255
+ # of the server clock, so a retried request is re-signed.
256
+ headers = self._headers(request)
257
+ started = time.monotonic()
258
+ try:
259
+ response = await self._client().request(
260
+ request.method, request.path, headers=headers, content=request.body
261
+ )
262
+ except httpx.TimeoutException as exc:
263
+ raise STXTransportException(
264
+ f"{request.method} {request.path} timed out after {self.timeout}s",
265
+ method=request.method,
266
+ path=request.path,
267
+ ) from exc
268
+ except httpx.HTTPError as exc:
269
+ raise STXTransportException(
270
+ f"{request.method} {request.path} failed: {exc}",
271
+ method=request.method,
272
+ path=request.path,
273
+ ) from exc
274
+ logger.debug(
275
+ "%s %s -> %s in %.0f ms",
276
+ request.method,
277
+ request.path,
278
+ response.status_code,
279
+ (time.monotonic() - started) * 1000,
280
+ )
281
+ return decode_response(
282
+ request, response.status_code, response.content, response.headers.get("Retry-After")
283
+ )
284
+
285
+ async def _send(self, request: PreparedRequest) -> Dict[str, Any]:
286
+ attempt = 1
287
+ while True:
288
+ try:
289
+ return await self._send_once(request)
290
+ except STXException as exc:
291
+ if not self.retry.should_retry(exc, attempt, request.idempotent):
292
+ raise
293
+ delay = self.retry.compute_backoff(attempt, exc)
294
+ logger.info(
295
+ "%s %s failed (%s); retrying in %.2fs", request.method, request.path, exc, delay
296
+ )
297
+ await self._sleep(delay)
298
+ attempt += 1
299
+
300
+ async def _call(self, operation_id: str, **kwargs: Any) -> Dict[str, Any]:
301
+ return await self._send(build_request(operation_id, **kwargs))
302
+
303
+ async def _one(self, operation_id: str, model: Type[T], **kwargs: Any) -> T:
304
+ body = await self._call(operation_id, **kwargs)
305
+ key = OPERATIONS[operation_id].response_key
306
+ return model.model_validate(body[key] if key else body) # type: ignore[attr-defined]
307
+
308
+ async def _list(self, operation_id: str, model: Type[T], **kwargs: Any) -> List[T]:
309
+ body = await self._call(operation_id, **kwargs)
310
+ items, _ = split_page(operation_id, body)
311
+ return [model.model_validate(item) for item in items] # type: ignore[attr-defined]
312
+
313
+ async def _page(self, operation_id: str, model: Type[T], query: Dict[str, Any]) -> Page[T]:
314
+ body = await self._call(operation_id, query=query)
315
+ items, cursor = split_page(operation_id, body)
316
+ return Page([model.model_validate(item) for item in items], cursor) # type: ignore[attr-defined]
317
+
318
+ async def _iterate(
319
+ self, operation_id: str, model: Type[T], query: Dict[str, Any]
320
+ ) -> AsyncIterator[T]:
321
+ query = dict(query)
322
+ while True:
323
+ page = await self._page(operation_id, model, query)
324
+ for item in page:
325
+ yield item
326
+ if not page.cursor:
327
+ return
328
+ query["cursor"] = page.cursor
329
+
330
+ # ------------------------------------------------------------------
331
+ # Identity
332
+ # ------------------------------------------------------------------
333
+
334
+ async def me(self) -> models.Me:
335
+ """``GET /api/v1/me``: who this key belongs to.
336
+
337
+ Returns the ``user_id`` the account channels are keyed by, the
338
+ ``account_id``, and the key's ``scope`` (``read_only`` or
339
+ ``read_write``).
340
+ """
341
+ self._me = await self._one("me_get", models.Me)
342
+ return self._me
343
+
344
+ async def user_id(self) -> str:
345
+ """The user id from :meth:`me`, fetched once and cached."""
346
+ if self._me is None or not self._me.user_id:
347
+ await self.me()
348
+ assert self._me is not None and self._me.user_id
349
+ return self._me.user_id
350
+
351
+ # ------------------------------------------------------------------
352
+ # Markets and events
353
+ # ------------------------------------------------------------------
354
+
355
+ @staticmethod
356
+ def _markets_query(
357
+ market_ids: StrList,
358
+ event_ids: StrList,
359
+ status: StrList,
360
+ trading: Optional[bool],
361
+ sports: StrList,
362
+ competitions: StrList,
363
+ sort_by: Optional[str],
364
+ sort_direction: Optional[str],
365
+ limit: Optional[int],
366
+ cursor: Optional[str],
367
+ ) -> Dict[str, Any]:
368
+ return {
369
+ "market_ids": _ids(market_ids),
370
+ "event_ids": _ids(event_ids),
371
+ "status": _ids(status),
372
+ "trading": trading,
373
+ "sports": _ids(sports),
374
+ "competitions": _ids(competitions),
375
+ "sort_by[name]": sort_by,
376
+ "sort_by[direction]": sort_direction,
377
+ "limit": limit,
378
+ "cursor": cursor,
379
+ }
380
+
381
+ async def markets(
382
+ self,
383
+ *,
384
+ market_ids: StrList = None,
385
+ event_ids: StrList = None,
386
+ status: StrList = None,
387
+ trading: Optional[bool] = None,
388
+ sports: StrList = None,
389
+ competitions: StrList = None,
390
+ sort_by: Optional[str] = None,
391
+ sort_direction: Optional[str] = None,
392
+ limit: Optional[int] = None,
393
+ cursor: Optional[str] = None,
394
+ ) -> Page[models.Market]:
395
+ """``GET /api/v1/markets``: one page of markets.
396
+
397
+ Filters combine with AND; list filters match any value. ``status``
398
+ takes one status or a list (``["pre_open", "open"]``). ``sort_by``
399
+ is ``"event_start"`` with ``sort_direction`` ``"asc"`` or
400
+ ``"desc"``. ``limit`` defaults to 100, maximum 200.
401
+ """
402
+ query = self._markets_query(
403
+ market_ids, event_ids, status, trading, sports, competitions,
404
+ sort_by, sort_direction, limit, cursor,
405
+ ) # fmt: skip
406
+ return await self._page("markets_get", models.Market, query)
407
+
408
+ def iter_markets(
409
+ self,
410
+ *,
411
+ market_ids: StrList = None,
412
+ event_ids: StrList = None,
413
+ status: StrList = None,
414
+ trading: Optional[bool] = None,
415
+ sports: StrList = None,
416
+ competitions: StrList = None,
417
+ sort_by: Optional[str] = None,
418
+ sort_direction: Optional[str] = None,
419
+ limit: Optional[int] = None,
420
+ ) -> AsyncIterator[models.Market]:
421
+ """Every market matching the filters, following the cursor."""
422
+ query = self._markets_query(
423
+ market_ids, event_ids, status, trading, sports, competitions,
424
+ sort_by, sort_direction, limit, None,
425
+ ) # fmt: skip
426
+ return self._iterate("markets_get", models.Market, query)
427
+
428
+ async def market(self, market_id: str) -> models.Market:
429
+ """One market by id. Raises ``STXNotFoundException`` if there is none."""
430
+ page = await self.markets(market_ids=[market_id], limit=1)
431
+ if not page.items:
432
+ raise STXNotFoundException(f"Market {market_id} not found", status_code=404)
433
+ return page.items[0]
434
+
435
+ @staticmethod
436
+ def _events_query(
437
+ event_ids: StrList,
438
+ sports: StrList,
439
+ competitions: StrList,
440
+ event_types: StrList,
441
+ title: Optional[str],
442
+ status: Optional[str],
443
+ promoted: Optional[bool],
444
+ sort_by: Optional[str],
445
+ sort_direction: Optional[str],
446
+ limit: Optional[int],
447
+ cursor: Optional[str],
448
+ ) -> Dict[str, Any]:
449
+ return {
450
+ "event_ids": _ids(event_ids),
451
+ "sports": _ids(sports),
452
+ "competitions": _ids(competitions),
453
+ "event_types": _ids(event_types),
454
+ "title": title,
455
+ "status": status,
456
+ "promoted": promoted,
457
+ "sort_by[name]": sort_by,
458
+ "sort_by[direction]": sort_direction,
459
+ "limit": limit,
460
+ "cursor": cursor,
461
+ }
462
+
463
+ async def events(
464
+ self,
465
+ *,
466
+ event_ids: StrList = None,
467
+ sports: StrList = None,
468
+ competitions: StrList = None,
469
+ event_types: StrList = None,
470
+ title: Optional[str] = None,
471
+ status: Optional[str] = None,
472
+ promoted: Optional[bool] = None,
473
+ sort_by: Optional[str] = None,
474
+ sort_direction: Optional[str] = None,
475
+ limit: Optional[int] = None,
476
+ cursor: Optional[str] = None,
477
+ ) -> Page[models.Event]:
478
+ """``GET /api/v1/events``: one page of events.
479
+
480
+ ``status`` is one of ``scheduled``, ``in_progress``, ``completed``,
481
+ ``cancelled``. ``sort_by`` is ``"start_time"``.
482
+ """
483
+ query = self._events_query(
484
+ event_ids, sports, competitions, event_types, title, status,
485
+ promoted, sort_by, sort_direction, limit, cursor,
486
+ ) # fmt: skip
487
+ return await self._page("events_get", models.Event, query)
488
+
489
+ def iter_events(
490
+ self,
491
+ *,
492
+ event_ids: StrList = None,
493
+ sports: StrList = None,
494
+ competitions: StrList = None,
495
+ event_types: StrList = None,
496
+ title: Optional[str] = None,
497
+ status: Optional[str] = None,
498
+ promoted: Optional[bool] = None,
499
+ sort_by: Optional[str] = None,
500
+ sort_direction: Optional[str] = None,
501
+ limit: Optional[int] = None,
502
+ ) -> AsyncIterator[models.Event]:
503
+ """Every event matching the filters, following the cursor."""
504
+ query = self._events_query(
505
+ event_ids, sports, competitions, event_types, title, status,
506
+ promoted, sort_by, sort_direction, limit, None,
507
+ ) # fmt: skip
508
+ return self._iterate("events_get", models.Event, query)
509
+
510
+ # ------------------------------------------------------------------
511
+ # Orders
512
+ # ------------------------------------------------------------------
513
+
514
+ @staticmethod
515
+ def _orders_query(
516
+ order_ids: StrList,
517
+ client_order_ids: StrList,
518
+ market_ids: StrList,
519
+ status: StrList,
520
+ limit: Optional[int],
521
+ cursor: Optional[str],
522
+ ) -> Dict[str, Any]:
523
+ return {
524
+ "order_ids": _ids(order_ids),
525
+ "client_order_ids": _ids(client_order_ids),
526
+ "market_ids": _ids(market_ids),
527
+ "status": _ids(status),
528
+ "limit": limit,
529
+ "cursor": cursor,
530
+ }
531
+
532
+ async def orders(
533
+ self,
534
+ *,
535
+ order_ids: StrList = None,
536
+ client_order_ids: StrList = None,
537
+ market_ids: StrList = None,
538
+ status: StrList = None,
539
+ limit: Optional[int] = None,
540
+ cursor: Optional[str] = None,
541
+ ) -> Page[models.Order]:
542
+ """``GET /api/v1/orders``: one page of your orders, newest first.
543
+
544
+ ``status`` takes one status or a list, e.g. ``["open", "delayed"]``.
545
+ """
546
+ query = self._orders_query(order_ids, client_order_ids, market_ids, status, limit, cursor)
547
+ return await self._page("orders_get", models.Order, query)
548
+
549
+ def iter_orders(
550
+ self,
551
+ *,
552
+ order_ids: StrList = None,
553
+ client_order_ids: StrList = None,
554
+ market_ids: StrList = None,
555
+ status: StrList = None,
556
+ limit: Optional[int] = None,
557
+ ) -> AsyncIterator[models.Order]:
558
+ """Every order matching the filters, following the cursor."""
559
+ query = self._orders_query(order_ids, client_order_ids, market_ids, status, limit, None)
560
+ return self._iterate("orders_get", models.Order, query)
561
+
562
+ async def order(self, order_id: str) -> models.Order:
563
+ """``GET /api/v1/orders/{order_id}``: one of your orders."""
564
+ return await self._one("orders__id_get", models.Order, path_params={"order_id": order_id})
565
+
566
+ async def place_order(
567
+ self,
568
+ market_id: str,
569
+ action: str,
570
+ order_type: str,
571
+ *,
572
+ price: Optional[Amount] = None,
573
+ quantity: Amount,
574
+ client_order_id: Optional[str] = None,
575
+ expiration: Optional[str] = None,
576
+ expiration_time: Optional[int] = None,
577
+ cancel_on_disconnect: Optional[bool] = None,
578
+ device_id: Optional[str] = None,
579
+ ) -> models.Order:
580
+ """``POST /api/v1/orders``: place one order.
581
+
582
+ ``action`` is ``"buy"`` or ``"sell"``; ``order_type`` is ``"limit"``
583
+ or ``"market"``. ``price`` (dollars, required for a limit order) and
584
+ ``quantity`` (contracts) are decimal strings, e.g.
585
+ ``price="0.56", quantity="2"``. Floats are refused client-side.
586
+
587
+ ``expiration`` is ``"good_till_start"`` or ``"good_till_time"``; the
588
+ latter needs ``expiration_time`` in Unix **microseconds**.
589
+ ``cancel_on_disconnect=True`` opts this order into cancel-on-
590
+ disconnect, which also needs the ``orders`` channel joined with it
591
+ armed (see :meth:`stx.STXWebSocket.orders`).
592
+
593
+ The exchange validates the order: a bad price step or a fractional
594
+ quantity raises ``STXValidationException`` (400) or
595
+ ``STXRejectedException`` (422) with the API's message.
596
+
597
+ A ``POST`` is never retried after a 5xx or a dropped connection;
598
+ pass ``client_order_id`` so you can look the order up if that
599
+ happens.
600
+ """
601
+ body = order_body(
602
+ market_id,
603
+ action,
604
+ order_type,
605
+ price=price,
606
+ quantity=quantity,
607
+ client_order_id=client_order_id,
608
+ expiration=expiration,
609
+ expiration_time=expiration_time,
610
+ cancel_on_disconnect=cancel_on_disconnect,
611
+ device_id=device_id,
612
+ )
613
+ return await self._one("orders_post", models.Order, body=body)
614
+
615
+ async def place_orders(
616
+ self,
617
+ orders: Sequence[Mapping[str, Any]],
618
+ *,
619
+ geo_location: Optional[str] = None,
620
+ ) -> List[BatchOrderResult]:
621
+ """``POST /api/v1/orders/batched``: place several orders in one call.
622
+
623
+ Each order is a dict with the same fields as :meth:`place_order`::
624
+
625
+ await client.place_orders([
626
+ {"market_id": m, "action": "buy", "order_type": "limit",
627
+ "price": "0.01", "quantity": "1"},
628
+ {"market_id": m, "action": "buy", "order_type": "limit",
629
+ "price": "0.02", "quantity": "1"},
630
+ ])
631
+
632
+ Returns one :class:`stx.BatchOrderResult` per order, in order; a
633
+ rejected order has ``errors`` instead of ``order`` and does not stop
634
+ the others.
635
+ """
636
+ body: Dict[str, Any] = {"orders": [_normalize_order(o) for o in orders]}
637
+ if geo_location is not None:
638
+ body["geo_location"] = geo_location
639
+ return await self._list("orders_batched_post", BatchOrderResult, body=body)
640
+
641
+ async def cancel_order(self, order_id: str) -> models.Cancellation:
642
+ """``DELETE /api/v1/orders/{order_id}``: request a cancel.
643
+
644
+ A cancel is a request: a fill already in flight can still land.
645
+ """
646
+ return await self._one(
647
+ "orders__order_id_delete", models.Cancellation, path_params={"order_id": order_id}
648
+ )
649
+
650
+ async def cancel_orders(self, order_ids: Sequence[str]) -> List[models.Cancellation]:
651
+ """``DELETE /api/v1/orders/batched``: cancel the named orders."""
652
+ body = {"orders": [{"order_id": oid} for oid in order_ids]}
653
+ return await self._list("orders_batched_delete", models.Cancellation, body=body)
654
+
655
+ async def cancel_all_orders(self) -> List[models.Cancellation]:
656
+ """``DELETE /api/v1/orders/all``: cancel everything resting on the account."""
657
+ return await self._list("orders_all_delete", models.Cancellation)
658
+
659
+ # ------------------------------------------------------------------
660
+ # Fills, positions, balance
661
+ # ------------------------------------------------------------------
662
+
663
+ @staticmethod
664
+ def _fills_query(
665
+ market_ids: StrList,
666
+ order_ids: StrList,
667
+ status: Optional[str],
668
+ limit: Optional[int],
669
+ cursor: Optional[str],
670
+ ) -> Dict[str, Any]:
671
+ return {
672
+ "market_ids": _ids(market_ids),
673
+ "order_ids": _ids(order_ids),
674
+ "status": status,
675
+ "limit": limit,
676
+ "cursor": cursor,
677
+ }
678
+
679
+ async def fills(
680
+ self,
681
+ *,
682
+ market_ids: StrList = None,
683
+ order_ids: StrList = None,
684
+ status: Optional[str] = None,
685
+ limit: Optional[int] = None,
686
+ cursor: Optional[str] = None,
687
+ ) -> Page[models.Fill]:
688
+ """``GET /api/v1/fills``: one page of your executions.
689
+
690
+ ``order_ids`` narrows to the fills of those orders. ``status`` is
691
+ one of ``created``, ``open``, ``settled``, ``cancelled``.
692
+ """
693
+ query = self._fills_query(market_ids, order_ids, status, limit, cursor)
694
+ return await self._page("fills_get", models.Fill, query)
695
+
696
+ def iter_fills(
697
+ self,
698
+ *,
699
+ market_ids: StrList = None,
700
+ order_ids: StrList = None,
701
+ status: Optional[str] = None,
702
+ limit: Optional[int] = None,
703
+ ) -> AsyncIterator[models.Fill]:
704
+ """Every fill matching the filters, following the cursor."""
705
+ query = self._fills_query(market_ids, order_ids, status, limit, None)
706
+ return self._iterate("fills_get", models.Fill, query)
707
+
708
+ async def positions(self, *, market_ids: StrList = None) -> List[models.Position]:
709
+ """``GET /api/v1/positions``: your open positions.
710
+
711
+ The same objects the ``positions`` channel sends on join.
712
+ ``position`` is positive when long, negative when short.
713
+ """
714
+ return await self._list(
715
+ "positions_get", models.Position, query={"market_ids": _ids(market_ids)}
716
+ )
717
+
718
+ async def balance(self) -> models.Balance:
719
+ """``GET /api/v1/account/balance``: balance, liabilities and fee schedule.
720
+
721
+ The same object the ``balances`` channel pushes.
722
+ """
723
+ return await self._one("account_balance_get", models.Balance)
724
+
725
+ @staticmethod
726
+ def _market_stats_query(
727
+ market_ids: StrList,
728
+ event_ids: StrList,
729
+ exclude_zero_settlements: Optional[bool],
730
+ from_time: Optional[int],
731
+ to_time: Optional[int],
732
+ sports: StrList,
733
+ competitions: StrList,
734
+ limit: Optional[int],
735
+ cursor: Optional[str],
736
+ ) -> Dict[str, Any]:
737
+ return {
738
+ "market_ids": _ids(market_ids),
739
+ "event_ids": _ids(event_ids),
740
+ "exclude_zero_settlements": exclude_zero_settlements,
741
+ "from_time": from_time,
742
+ "to_time": to_time,
743
+ "sports": _ids(sports),
744
+ "competitions": _ids(competitions),
745
+ "limit": limit,
746
+ "cursor": cursor,
747
+ }
748
+
749
+ async def account_market_stats(
750
+ self,
751
+ *,
752
+ market_ids: StrList = None,
753
+ event_ids: StrList = None,
754
+ exclude_zero_settlements: Optional[bool] = None,
755
+ from_time: Optional[int] = None,
756
+ to_time: Optional[int] = None,
757
+ sports: StrList = None,
758
+ competitions: StrList = None,
759
+ limit: Optional[int] = None,
760
+ cursor: Optional[str] = None,
761
+ ) -> Page[models.MarketStat]:
762
+ """``GET /api/v1/account/market_stats``: your exposure and P&L per market.
763
+
764
+ Not the public ``market_stats`` channel, which carries prices.
765
+ """
766
+ query = self._market_stats_query(
767
+ market_ids, event_ids, exclude_zero_settlements, from_time, to_time,
768
+ sports, competitions, limit, cursor,
769
+ ) # fmt: skip
770
+ return await self._page("account_market_stats_get", models.MarketStat, query)
771
+
772
+ def iter_account_market_stats(
773
+ self,
774
+ *,
775
+ market_ids: StrList = None,
776
+ event_ids: StrList = None,
777
+ exclude_zero_settlements: Optional[bool] = None,
778
+ from_time: Optional[int] = None,
779
+ to_time: Optional[int] = None,
780
+ sports: StrList = None,
781
+ competitions: StrList = None,
782
+ limit: Optional[int] = None,
783
+ ) -> AsyncIterator[models.MarketStat]:
784
+ """Every per-market stat row, following the cursor."""
785
+ query = self._market_stats_query(
786
+ market_ids, event_ids, exclude_zero_settlements, from_time, to_time,
787
+ sports, competitions, limit, None,
788
+ ) # fmt: skip
789
+ return self._iterate("account_market_stats_get", models.MarketStat, query)
790
+
791
+ # ------------------------------------------------------------------
792
+ # Portfolio history
793
+ # ------------------------------------------------------------------
794
+
795
+ async def settlements(
796
+ self,
797
+ *,
798
+ market_ids: StrList = None,
799
+ type: Optional[str] = None,
800
+ limit: Optional[int] = None,
801
+ cursor: Optional[str] = None,
802
+ ) -> Page[models.Settlement]:
803
+ """``GET /api/v1/portfolio/settlements``: settlements on your account.
804
+
805
+ ``type`` is one of ``closed_short``, ``closed_long``,
806
+ ``expired_short``, ``expired_long``.
807
+ """
808
+ query = {"market_ids": _ids(market_ids), "type": type, "limit": limit, "cursor": cursor}
809
+ return await self._page("portfolio_settlements_get", models.Settlement, query)
810
+
811
+ def iter_settlements(
812
+ self,
813
+ *,
814
+ market_ids: StrList = None,
815
+ type: Optional[str] = None,
816
+ limit: Optional[int] = None,
817
+ ) -> AsyncIterator[models.Settlement]:
818
+ """Every settlement, following the cursor."""
819
+ query = {"market_ids": _ids(market_ids), "type": type, "limit": limit}
820
+ return self._iterate("portfolio_settlements_get", models.Settlement, query)
821
+
822
+ async def deposits(
823
+ self, *, limit: Optional[int] = None, cursor: Optional[str] = None
824
+ ) -> Page[models.PaymentTransaction]:
825
+ """``GET /api/v1/portfolio/deposits``."""
826
+ query = {"limit": limit, "cursor": cursor}
827
+ return await self._page("portfolio_deposits_get", models.PaymentTransaction, query)
828
+
829
+ def iter_deposits(
830
+ self, *, limit: Optional[int] = None
831
+ ) -> AsyncIterator[models.PaymentTransaction]:
832
+ """Every deposit, following the cursor."""
833
+ return self._iterate("portfolio_deposits_get", models.PaymentTransaction, {"limit": limit})
834
+
835
+ async def withdrawals(
836
+ self, *, limit: Optional[int] = None, cursor: Optional[str] = None
837
+ ) -> Page[models.PaymentTransaction]:
838
+ """``GET /api/v1/portfolio/withdrawals``."""
839
+ query = {"limit": limit, "cursor": cursor}
840
+ return await self._page("portfolio_withdrawals_get", models.PaymentTransaction, query)
841
+
842
+ def iter_withdrawals(
843
+ self, *, limit: Optional[int] = None
844
+ ) -> AsyncIterator[models.PaymentTransaction]:
845
+ """Every withdrawal, following the cursor."""
846
+ return self._iterate(
847
+ "portfolio_withdrawals_get", models.PaymentTransaction, {"limit": limit}
848
+ )
849
+
850
+ async def adjustments(
851
+ self, *, limit: Optional[int] = None, cursor: Optional[str] = None
852
+ ) -> Page[models.PaymentTransaction]:
853
+ """``GET /api/v1/portfolio/adjustments``: manual balance adjustments."""
854
+ query = {"limit": limit, "cursor": cursor}
855
+ return await self._page("portfolio_adjustments_get", models.PaymentTransaction, query)
856
+
857
+ def iter_adjustments(
858
+ self, *, limit: Optional[int] = None
859
+ ) -> AsyncIterator[models.PaymentTransaction]:
860
+ """Every adjustment, following the cursor."""
861
+ return self._iterate(
862
+ "portfolio_adjustments_get", models.PaymentTransaction, {"limit": limit}
863
+ )
864
+
865
+ async def fees(
866
+ self, *, limit: Optional[int] = None, cursor: Optional[str] = None
867
+ ) -> Page[models.FeeTransaction]:
868
+ """``GET /api/v1/portfolio/fees``: fee and fee-refund entries."""
869
+ query = {"limit": limit, "cursor": cursor}
870
+ return await self._page("portfolio_fees_get", models.FeeTransaction, query)
871
+
872
+ def iter_fees(self, *, limit: Optional[int] = None) -> AsyncIterator[models.FeeTransaction]:
873
+ """Every fee entry, following the cursor."""
874
+ return self._iterate("portfolio_fees_get", models.FeeTransaction, {"limit": limit})
875
+
876
+ async def loyalty(
877
+ self, *, limit: Optional[int] = None, cursor: Optional[str] = None
878
+ ) -> Page[models.Transaction]:
879
+ """``GET /api/v1/portfolio/loyalty``: loyalty entries."""
880
+ query = {"limit": limit, "cursor": cursor}
881
+ return await self._page("portfolio_loyalty_get", models.Transaction, query)
882
+
883
+ def iter_loyalty(self, *, limit: Optional[int] = None) -> AsyncIterator[models.Transaction]:
884
+ """Every loyalty entry, following the cursor."""
885
+ return self._iterate("portfolio_loyalty_get", models.Transaction, {"limit": limit})
886
+
887
+ # ------------------------------------------------------------------
888
+ # Terms
889
+ # ------------------------------------------------------------------
890
+
891
+ async def accept_terms(
892
+ self,
893
+ device_id: str,
894
+ *,
895
+ accept_terms: bool = True,
896
+ accept_privacy: bool = True,
897
+ accept_house_rules: Optional[bool] = None,
898
+ ) -> str:
899
+ """``POST /api/v1/tnc/accept``: accept the current terms. Returns the
900
+ server's message."""
901
+ body: Dict[str, Any] = {
902
+ "device_id": device_id,
903
+ "accept_terms": accept_terms,
904
+ "accept_privacy": accept_privacy,
905
+ }
906
+ if accept_house_rules is not None:
907
+ body["accept_house_rules"] = accept_house_rules
908
+ result = await self._call("tnc_accept_post", body=body)
909
+ return str(result.get("message", ""))
910
+
911
+ # ------------------------------------------------------------------
912
+ # WebSocket
913
+ # ------------------------------------------------------------------
914
+
915
+ def websocket(self, **kwargs: Any) -> STXWebSocket:
916
+ """An :class:`stx.STXWebSocket` using this client's host and key.
917
+
918
+ Account channels need your user id; the socket fetches it through
919
+ this client's :meth:`me` on first use.
920
+ """
921
+ from stx._ws import STXWebSocket
922
+
923
+ return STXWebSocket(rest=self, **kwargs)