stx-python 0.6.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.
- stx/__init__.py +61 -0
- stx/_async_client.py +923 -0
- stx/_base.py +64 -0
- stx/_client.py +502 -0
- stx/_config.py +107 -0
- stx/_http.py +131 -0
- stx/_money.py +122 -0
- stx/_operations.py +259 -0
- stx/_paging.py +59 -0
- stx/_results.py +29 -0
- stx/_retry.py +80 -0
- stx/_settings.py +203 -0
- stx/_signing.py +229 -0
- stx/_version.py +12 -0
- stx/_ws.py +1016 -0
- stx/enums.py +13 -0
- stx/exceptions.py +156 -0
- stx/models.py +2034 -0
- stx/py.typed +0 -0
- stx_python-0.6.0.dist-info/METADATA +121 -0
- stx_python-0.6.0.dist-info/RECORD +24 -0
- stx_python-0.6.0.dist-info/WHEEL +5 -0
- stx_python-0.6.0.dist-info/licenses/LICENSE +21 -0
- stx_python-0.6.0.dist-info/top_level.txt +1 -0
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)
|