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/__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.0rc1.dist-info/METADATA +121 -0
- stx_python-0.6.0rc1.dist-info/RECORD +24 -0
- stx_python-0.6.0rc1.dist-info/WHEEL +5 -0
- stx_python-0.6.0rc1.dist-info/licenses/LICENSE +21 -0
- stx_python-0.6.0rc1.dist-info/top_level.txt +1 -0
stx/_base.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Base class for every response model the SDK returns.
|
|
2
|
+
|
|
3
|
+
Two rules shape it:
|
|
4
|
+
|
|
5
|
+
1. Every field is optional and unknown fields are kept. The server adds
|
|
6
|
+
fields over time, and a response missing a field the SDK knows about
|
|
7
|
+
should not stop a trading loop. Extra fields are reachable as
|
|
8
|
+
attributes and through ``model_dump()``.
|
|
9
|
+
2. A field whose value does not match its declared type is kept as the
|
|
10
|
+
raw value rather than failing the whole response. The specification
|
|
11
|
+
and the server can disagree on a display field, and that should cost
|
|
12
|
+
the caller one field, not the whole list of markets. Each such field
|
|
13
|
+
is logged once at WARNING so the mismatch is visible.
|
|
14
|
+
|
|
15
|
+
Money and quantity fields are declared ``str`` and the API sends strings,
|
|
16
|
+
so they pass through untouched: ``"0.5600"`` stays ``"0.5600"``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from typing import Any, Dict, Set, Tuple
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("stx")
|
|
27
|
+
|
|
28
|
+
_warned: Set[Tuple[str, str]] = set()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class STXModel(BaseModel):
|
|
32
|
+
"""Pydantic v2 model with lenient parsing. See the module docstring."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="wrap")
|
|
37
|
+
@classmethod
|
|
38
|
+
def _lenient(cls, data: Any, handler: Any) -> Any:
|
|
39
|
+
# Normal path: the payload matches the declared types.
|
|
40
|
+
try:
|
|
41
|
+
return handler(data)
|
|
42
|
+
except ValidationError as exc:
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
raise
|
|
45
|
+
# Collect the top-level fields that failed, validate without
|
|
46
|
+
# them, then put their raw values back.
|
|
47
|
+
bad = {err["loc"][0] for err in exc.errors() if err.get("loc")}
|
|
48
|
+
bad = {name for name in bad if isinstance(name, str) and name in data}
|
|
49
|
+
if not bad:
|
|
50
|
+
raise
|
|
51
|
+
clean: Dict[str, Any] = {k: v for k, v in data.items() if k not in bad}
|
|
52
|
+
model = handler(clean)
|
|
53
|
+
for name in sorted(bad):
|
|
54
|
+
key = (cls.__name__, name)
|
|
55
|
+
if key not in _warned:
|
|
56
|
+
_warned.add(key)
|
|
57
|
+
logger.warning(
|
|
58
|
+
"%s.%s did not match its declared type; keeping the raw value %r",
|
|
59
|
+
cls.__name__,
|
|
60
|
+
name,
|
|
61
|
+
data[name],
|
|
62
|
+
)
|
|
63
|
+
object.__setattr__(model, name, data[name])
|
|
64
|
+
return model
|
stx/_client.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""``STX``: the blocking client, a thin wrapper over :class:`stx.AsyncSTX`.
|
|
2
|
+
|
|
3
|
+
Each method runs the matching ``AsyncSTX`` coroutine on a private event
|
|
4
|
+
loop in a background thread and returns its result, so the sync and async
|
|
5
|
+
clients cannot drift apart: there is one implementation. Because the loop
|
|
6
|
+
is private, ``STX`` also works inside Jupyter or any program that already
|
|
7
|
+
runs an event loop.
|
|
8
|
+
|
|
9
|
+
``iter_*`` methods return ordinary iterators that fetch the next page when
|
|
10
|
+
the current one runs out.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import threading
|
|
17
|
+
from typing import (
|
|
18
|
+
Any,
|
|
19
|
+
AsyncIterator,
|
|
20
|
+
Coroutine,
|
|
21
|
+
Iterator,
|
|
22
|
+
List,
|
|
23
|
+
Mapping,
|
|
24
|
+
Optional,
|
|
25
|
+
Sequence,
|
|
26
|
+
TypeVar,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from stx import models
|
|
30
|
+
from stx._async_client import Amount, AsyncSTX, StrList
|
|
31
|
+
from stx._paging import Page
|
|
32
|
+
from stx._results import BatchOrderResult
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _LoopThread:
|
|
38
|
+
"""An event loop running forever in a daemon thread."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self.loop = asyncio.new_event_loop()
|
|
42
|
+
self.thread = threading.Thread(target=self.loop.run_forever, name="stx-sync", daemon=True)
|
|
43
|
+
self.thread.start()
|
|
44
|
+
|
|
45
|
+
def run(self, coro: Coroutine[Any, Any, T]) -> T:
|
|
46
|
+
return asyncio.run_coroutine_threadsafe(coro, self.loop).result()
|
|
47
|
+
|
|
48
|
+
def stop(self) -> None:
|
|
49
|
+
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
50
|
+
self.thread.join(timeout=5)
|
|
51
|
+
self.loop.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class STX:
|
|
55
|
+
"""Blocking client for the STX exchange.
|
|
56
|
+
|
|
57
|
+
Takes the same arguments as :class:`stx.AsyncSTX` and has the same
|
|
58
|
+
methods, without ``await``::
|
|
59
|
+
|
|
60
|
+
with STX(profile="us-demo") as client:
|
|
61
|
+
print(client.me().user_id)
|
|
62
|
+
for market in client.markets(status="open", limit=5):
|
|
63
|
+
print(market.symbol, market.last_traded_price)
|
|
64
|
+
|
|
65
|
+
For WebSocket channels use :class:`stx.STXWebSocket`, which is async.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
69
|
+
self._loop = _LoopThread()
|
|
70
|
+
try:
|
|
71
|
+
self._async = self._loop.run(self._make(kwargs))
|
|
72
|
+
except BaseException:
|
|
73
|
+
self._loop.stop()
|
|
74
|
+
raise
|
|
75
|
+
self.base_url = self._async.base_url
|
|
76
|
+
self.socket_url = self._async.socket_url
|
|
77
|
+
self.credentials = self._async.credentials
|
|
78
|
+
self.profile = self._async.profile
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
async def _make(kwargs: Mapping[str, Any]) -> AsyncSTX:
|
|
82
|
+
return AsyncSTX(**kwargs)
|
|
83
|
+
|
|
84
|
+
def __repr__(self) -> str:
|
|
85
|
+
key = self.credentials.key_id if self.credentials else None
|
|
86
|
+
return f"STX(base_url={self.base_url!r}, key_id={key!r})"
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def async_client(self) -> AsyncSTX:
|
|
90
|
+
"""The underlying ``AsyncSTX`` (bound to this client's private loop)."""
|
|
91
|
+
return self._async
|
|
92
|
+
|
|
93
|
+
def __enter__(self) -> STX:
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def __exit__(self, *exc: Any) -> None:
|
|
97
|
+
self.close()
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
"""Close connections and stop the background loop. Safe to call twice."""
|
|
101
|
+
if self._loop is None:
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
self._loop.run(self._async.close())
|
|
105
|
+
finally:
|
|
106
|
+
self._loop.stop()
|
|
107
|
+
self._loop = None # type: ignore[assignment]
|
|
108
|
+
|
|
109
|
+
def _run(self, coro: Coroutine[Any, Any, T]) -> T:
|
|
110
|
+
if self._loop is None:
|
|
111
|
+
coro.close()
|
|
112
|
+
raise RuntimeError("This STX client is closed.")
|
|
113
|
+
return self._loop.run(coro)
|
|
114
|
+
|
|
115
|
+
def _iterate(self, agen: AsyncIterator[T]) -> Iterator[T]:
|
|
116
|
+
async def step() -> Any:
|
|
117
|
+
return await agen.__anext__()
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
while True:
|
|
121
|
+
try:
|
|
122
|
+
yield self._run(step())
|
|
123
|
+
except StopAsyncIteration:
|
|
124
|
+
return
|
|
125
|
+
finally:
|
|
126
|
+
closer = getattr(agen, "aclose", None)
|
|
127
|
+
if closer is not None and self._loop is not None:
|
|
128
|
+
self._run(closer())
|
|
129
|
+
|
|
130
|
+
# Identity --------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def me(self) -> models.Me:
|
|
133
|
+
"""``GET /api/v1/me``. See :meth:`AsyncSTX.me`."""
|
|
134
|
+
return self._run(self._async.me())
|
|
135
|
+
|
|
136
|
+
def user_id(self) -> str:
|
|
137
|
+
"""Your user id, fetched once. See :meth:`AsyncSTX.user_id`."""
|
|
138
|
+
return self._run(self._async.user_id())
|
|
139
|
+
|
|
140
|
+
# Markets and events ---------------------------------------------
|
|
141
|
+
|
|
142
|
+
def markets(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
market_ids: StrList = None,
|
|
146
|
+
event_ids: StrList = None,
|
|
147
|
+
status: StrList = None,
|
|
148
|
+
trading: Optional[bool] = None,
|
|
149
|
+
sports: StrList = None,
|
|
150
|
+
competitions: StrList = None,
|
|
151
|
+
sort_by: Optional[str] = None,
|
|
152
|
+
sort_direction: Optional[str] = None,
|
|
153
|
+
limit: Optional[int] = None,
|
|
154
|
+
cursor: Optional[str] = None,
|
|
155
|
+
) -> Page[models.Market]:
|
|
156
|
+
"""``GET /api/v1/markets``. See :meth:`AsyncSTX.markets`."""
|
|
157
|
+
return self._run(
|
|
158
|
+
self._async.markets(
|
|
159
|
+
market_ids=market_ids, event_ids=event_ids, status=status, trading=trading,
|
|
160
|
+
sports=sports, competitions=competitions, sort_by=sort_by,
|
|
161
|
+
sort_direction=sort_direction, limit=limit, cursor=cursor,
|
|
162
|
+
)
|
|
163
|
+
) # fmt: skip
|
|
164
|
+
|
|
165
|
+
def iter_markets(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
market_ids: StrList = None,
|
|
169
|
+
event_ids: StrList = None,
|
|
170
|
+
status: StrList = None,
|
|
171
|
+
trading: Optional[bool] = None,
|
|
172
|
+
sports: StrList = None,
|
|
173
|
+
competitions: StrList = None,
|
|
174
|
+
sort_by: Optional[str] = None,
|
|
175
|
+
sort_direction: Optional[str] = None,
|
|
176
|
+
limit: Optional[int] = None,
|
|
177
|
+
) -> Iterator[models.Market]:
|
|
178
|
+
"""Every matching market, page by page."""
|
|
179
|
+
return self._iterate(
|
|
180
|
+
self._async.iter_markets(
|
|
181
|
+
market_ids=market_ids, event_ids=event_ids, status=status, trading=trading,
|
|
182
|
+
sports=sports, competitions=competitions, sort_by=sort_by,
|
|
183
|
+
sort_direction=sort_direction, limit=limit,
|
|
184
|
+
)
|
|
185
|
+
) # fmt: skip
|
|
186
|
+
|
|
187
|
+
def market(self, market_id: str) -> models.Market:
|
|
188
|
+
"""One market by id. See :meth:`AsyncSTX.market`."""
|
|
189
|
+
return self._run(self._async.market(market_id))
|
|
190
|
+
|
|
191
|
+
def events(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
event_ids: StrList = None,
|
|
195
|
+
sports: StrList = None,
|
|
196
|
+
competitions: StrList = None,
|
|
197
|
+
event_types: StrList = None,
|
|
198
|
+
title: Optional[str] = None,
|
|
199
|
+
status: Optional[str] = None,
|
|
200
|
+
promoted: Optional[bool] = None,
|
|
201
|
+
sort_by: Optional[str] = None,
|
|
202
|
+
sort_direction: Optional[str] = None,
|
|
203
|
+
limit: Optional[int] = None,
|
|
204
|
+
cursor: Optional[str] = None,
|
|
205
|
+
) -> Page[models.Event]:
|
|
206
|
+
"""``GET /api/v1/events``. See :meth:`AsyncSTX.events`."""
|
|
207
|
+
return self._run(
|
|
208
|
+
self._async.events(
|
|
209
|
+
event_ids=event_ids, sports=sports, competitions=competitions,
|
|
210
|
+
event_types=event_types, title=title, status=status, promoted=promoted,
|
|
211
|
+
sort_by=sort_by, sort_direction=sort_direction, limit=limit, cursor=cursor,
|
|
212
|
+
)
|
|
213
|
+
) # fmt: skip
|
|
214
|
+
|
|
215
|
+
def iter_events(
|
|
216
|
+
self,
|
|
217
|
+
*,
|
|
218
|
+
event_ids: StrList = None,
|
|
219
|
+
sports: StrList = None,
|
|
220
|
+
competitions: StrList = None,
|
|
221
|
+
event_types: StrList = None,
|
|
222
|
+
title: Optional[str] = None,
|
|
223
|
+
status: Optional[str] = None,
|
|
224
|
+
promoted: Optional[bool] = None,
|
|
225
|
+
sort_by: Optional[str] = None,
|
|
226
|
+
sort_direction: Optional[str] = None,
|
|
227
|
+
limit: Optional[int] = None,
|
|
228
|
+
) -> Iterator[models.Event]:
|
|
229
|
+
"""Every matching event, page by page."""
|
|
230
|
+
return self._iterate(
|
|
231
|
+
self._async.iter_events(
|
|
232
|
+
event_ids=event_ids, sports=sports, competitions=competitions,
|
|
233
|
+
event_types=event_types, title=title, status=status, promoted=promoted,
|
|
234
|
+
sort_by=sort_by, sort_direction=sort_direction, limit=limit,
|
|
235
|
+
)
|
|
236
|
+
) # fmt: skip
|
|
237
|
+
|
|
238
|
+
# Orders ------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def orders(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
order_ids: StrList = None,
|
|
244
|
+
client_order_ids: StrList = None,
|
|
245
|
+
market_ids: StrList = None,
|
|
246
|
+
status: StrList = None,
|
|
247
|
+
limit: Optional[int] = None,
|
|
248
|
+
cursor: Optional[str] = None,
|
|
249
|
+
) -> Page[models.Order]:
|
|
250
|
+
"""``GET /api/v1/orders``. See :meth:`AsyncSTX.orders`."""
|
|
251
|
+
return self._run(
|
|
252
|
+
self._async.orders(
|
|
253
|
+
order_ids=order_ids, client_order_ids=client_order_ids, market_ids=market_ids,
|
|
254
|
+
status=status, limit=limit, cursor=cursor,
|
|
255
|
+
)
|
|
256
|
+
) # fmt: skip
|
|
257
|
+
|
|
258
|
+
def iter_orders(
|
|
259
|
+
self,
|
|
260
|
+
*,
|
|
261
|
+
order_ids: StrList = None,
|
|
262
|
+
client_order_ids: StrList = None,
|
|
263
|
+
market_ids: StrList = None,
|
|
264
|
+
status: StrList = None,
|
|
265
|
+
limit: Optional[int] = None,
|
|
266
|
+
) -> Iterator[models.Order]:
|
|
267
|
+
"""Every matching order, page by page."""
|
|
268
|
+
return self._iterate(
|
|
269
|
+
self._async.iter_orders(
|
|
270
|
+
order_ids=order_ids, client_order_ids=client_order_ids, market_ids=market_ids,
|
|
271
|
+
status=status, limit=limit,
|
|
272
|
+
)
|
|
273
|
+
) # fmt: skip
|
|
274
|
+
|
|
275
|
+
def order(self, order_id: str) -> models.Order:
|
|
276
|
+
"""``GET /api/v1/orders/{order_id}``."""
|
|
277
|
+
return self._run(self._async.order(order_id))
|
|
278
|
+
|
|
279
|
+
def place_order(
|
|
280
|
+
self,
|
|
281
|
+
market_id: str,
|
|
282
|
+
action: str,
|
|
283
|
+
order_type: str,
|
|
284
|
+
*,
|
|
285
|
+
price: Optional[Amount] = None,
|
|
286
|
+
quantity: Amount,
|
|
287
|
+
client_order_id: Optional[str] = None,
|
|
288
|
+
expiration: Optional[str] = None,
|
|
289
|
+
expiration_time: Optional[int] = None,
|
|
290
|
+
cancel_on_disconnect: Optional[bool] = None,
|
|
291
|
+
device_id: Optional[str] = None,
|
|
292
|
+
) -> models.Order:
|
|
293
|
+
"""``POST /api/v1/orders``. See :meth:`AsyncSTX.place_order`."""
|
|
294
|
+
return self._run(
|
|
295
|
+
self._async.place_order(
|
|
296
|
+
market_id, action, order_type, price=price, quantity=quantity,
|
|
297
|
+
client_order_id=client_order_id, expiration=expiration,
|
|
298
|
+
expiration_time=expiration_time, cancel_on_disconnect=cancel_on_disconnect,
|
|
299
|
+
device_id=device_id,
|
|
300
|
+
)
|
|
301
|
+
) # fmt: skip
|
|
302
|
+
|
|
303
|
+
def place_orders(
|
|
304
|
+
self, orders: Sequence[Mapping[str, Any]], *, geo_location: Optional[str] = None
|
|
305
|
+
) -> List[BatchOrderResult]:
|
|
306
|
+
"""``POST /api/v1/orders/batched``. See :meth:`AsyncSTX.place_orders`."""
|
|
307
|
+
return self._run(self._async.place_orders(orders, geo_location=geo_location))
|
|
308
|
+
|
|
309
|
+
def cancel_order(self, order_id: str) -> models.Cancellation:
|
|
310
|
+
"""``DELETE /api/v1/orders/{order_id}``."""
|
|
311
|
+
return self._run(self._async.cancel_order(order_id))
|
|
312
|
+
|
|
313
|
+
def cancel_orders(self, order_ids: Sequence[str]) -> List[models.Cancellation]:
|
|
314
|
+
"""``DELETE /api/v1/orders/batched``."""
|
|
315
|
+
return self._run(self._async.cancel_orders(order_ids))
|
|
316
|
+
|
|
317
|
+
def cancel_all_orders(self) -> List[models.Cancellation]:
|
|
318
|
+
"""``DELETE /api/v1/orders/all``."""
|
|
319
|
+
return self._run(self._async.cancel_all_orders())
|
|
320
|
+
|
|
321
|
+
# Fills, positions, balance ----------------------------------------
|
|
322
|
+
|
|
323
|
+
def fills(
|
|
324
|
+
self,
|
|
325
|
+
*,
|
|
326
|
+
market_ids: StrList = None,
|
|
327
|
+
order_ids: StrList = None,
|
|
328
|
+
status: Optional[str] = None,
|
|
329
|
+
limit: Optional[int] = None,
|
|
330
|
+
cursor: Optional[str] = None,
|
|
331
|
+
) -> Page[models.Fill]:
|
|
332
|
+
"""``GET /api/v1/fills``. See :meth:`AsyncSTX.fills`."""
|
|
333
|
+
return self._run(
|
|
334
|
+
self._async.fills(
|
|
335
|
+
market_ids=market_ids, order_ids=order_ids, status=status, limit=limit,
|
|
336
|
+
cursor=cursor,
|
|
337
|
+
)
|
|
338
|
+
) # fmt: skip
|
|
339
|
+
|
|
340
|
+
def iter_fills(
|
|
341
|
+
self,
|
|
342
|
+
*,
|
|
343
|
+
market_ids: StrList = None,
|
|
344
|
+
order_ids: StrList = None,
|
|
345
|
+
status: Optional[str] = None,
|
|
346
|
+
limit: Optional[int] = None,
|
|
347
|
+
) -> Iterator[models.Fill]:
|
|
348
|
+
"""Every matching fill, page by page."""
|
|
349
|
+
return self._iterate(
|
|
350
|
+
self._async.iter_fills(
|
|
351
|
+
market_ids=market_ids, order_ids=order_ids, status=status, limit=limit
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def positions(self, *, market_ids: StrList = None) -> List[models.Position]:
|
|
356
|
+
"""``GET /api/v1/positions``. See :meth:`AsyncSTX.positions`."""
|
|
357
|
+
return self._run(self._async.positions(market_ids=market_ids))
|
|
358
|
+
|
|
359
|
+
def balance(self) -> models.Balance:
|
|
360
|
+
"""``GET /api/v1/account/balance``."""
|
|
361
|
+
return self._run(self._async.balance())
|
|
362
|
+
|
|
363
|
+
def account_market_stats(
|
|
364
|
+
self,
|
|
365
|
+
*,
|
|
366
|
+
market_ids: StrList = None,
|
|
367
|
+
event_ids: StrList = None,
|
|
368
|
+
exclude_zero_settlements: Optional[bool] = None,
|
|
369
|
+
from_time: Optional[int] = None,
|
|
370
|
+
to_time: Optional[int] = None,
|
|
371
|
+
sports: StrList = None,
|
|
372
|
+
competitions: StrList = None,
|
|
373
|
+
limit: Optional[int] = None,
|
|
374
|
+
cursor: Optional[str] = None,
|
|
375
|
+
) -> Page[models.MarketStat]:
|
|
376
|
+
"""``GET /api/v1/account/market_stats``."""
|
|
377
|
+
return self._run(
|
|
378
|
+
self._async.account_market_stats(
|
|
379
|
+
market_ids=market_ids, event_ids=event_ids,
|
|
380
|
+
exclude_zero_settlements=exclude_zero_settlements, from_time=from_time,
|
|
381
|
+
to_time=to_time, sports=sports, competitions=competitions, limit=limit,
|
|
382
|
+
cursor=cursor,
|
|
383
|
+
)
|
|
384
|
+
) # fmt: skip
|
|
385
|
+
|
|
386
|
+
def iter_account_market_stats(
|
|
387
|
+
self,
|
|
388
|
+
*,
|
|
389
|
+
market_ids: StrList = None,
|
|
390
|
+
event_ids: StrList = None,
|
|
391
|
+
exclude_zero_settlements: Optional[bool] = None,
|
|
392
|
+
from_time: Optional[int] = None,
|
|
393
|
+
to_time: Optional[int] = None,
|
|
394
|
+
sports: StrList = None,
|
|
395
|
+
competitions: StrList = None,
|
|
396
|
+
limit: Optional[int] = None,
|
|
397
|
+
) -> Iterator[models.MarketStat]:
|
|
398
|
+
"""Every per-market stat row, page by page."""
|
|
399
|
+
return self._iterate(
|
|
400
|
+
self._async.iter_account_market_stats(
|
|
401
|
+
market_ids=market_ids, event_ids=event_ids,
|
|
402
|
+
exclude_zero_settlements=exclude_zero_settlements, from_time=from_time,
|
|
403
|
+
to_time=to_time, sports=sports, competitions=competitions, limit=limit,
|
|
404
|
+
)
|
|
405
|
+
) # fmt: skip
|
|
406
|
+
|
|
407
|
+
# Portfolio history --------------------------------------------------
|
|
408
|
+
|
|
409
|
+
def settlements(
|
|
410
|
+
self,
|
|
411
|
+
*,
|
|
412
|
+
market_ids: StrList = None,
|
|
413
|
+
type: Optional[str] = None,
|
|
414
|
+
limit: Optional[int] = None,
|
|
415
|
+
cursor: Optional[str] = None,
|
|
416
|
+
) -> Page[models.Settlement]:
|
|
417
|
+
"""``GET /api/v1/portfolio/settlements``."""
|
|
418
|
+
return self._run(
|
|
419
|
+
self._async.settlements(market_ids=market_ids, type=type, limit=limit, cursor=cursor)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def iter_settlements(
|
|
423
|
+
self, *, market_ids: StrList = None, type: Optional[str] = None, limit: Optional[int] = None
|
|
424
|
+
) -> Iterator[models.Settlement]:
|
|
425
|
+
"""Every settlement, page by page."""
|
|
426
|
+
return self._iterate(
|
|
427
|
+
self._async.iter_settlements(market_ids=market_ids, type=type, limit=limit)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def deposits(
|
|
431
|
+
self, *, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
432
|
+
) -> Page[models.PaymentTransaction]:
|
|
433
|
+
"""``GET /api/v1/portfolio/deposits``."""
|
|
434
|
+
return self._run(self._async.deposits(limit=limit, cursor=cursor))
|
|
435
|
+
|
|
436
|
+
def iter_deposits(self, *, limit: Optional[int] = None) -> Iterator[models.PaymentTransaction]:
|
|
437
|
+
"""Every deposit, page by page."""
|
|
438
|
+
return self._iterate(self._async.iter_deposits(limit=limit))
|
|
439
|
+
|
|
440
|
+
def withdrawals(
|
|
441
|
+
self, *, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
442
|
+
) -> Page[models.PaymentTransaction]:
|
|
443
|
+
"""``GET /api/v1/portfolio/withdrawals``."""
|
|
444
|
+
return self._run(self._async.withdrawals(limit=limit, cursor=cursor))
|
|
445
|
+
|
|
446
|
+
def iter_withdrawals(
|
|
447
|
+
self, *, limit: Optional[int] = None
|
|
448
|
+
) -> Iterator[models.PaymentTransaction]:
|
|
449
|
+
"""Every withdrawal, page by page."""
|
|
450
|
+
return self._iterate(self._async.iter_withdrawals(limit=limit))
|
|
451
|
+
|
|
452
|
+
def adjustments(
|
|
453
|
+
self, *, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
454
|
+
) -> Page[models.PaymentTransaction]:
|
|
455
|
+
"""``GET /api/v1/portfolio/adjustments``."""
|
|
456
|
+
return self._run(self._async.adjustments(limit=limit, cursor=cursor))
|
|
457
|
+
|
|
458
|
+
def iter_adjustments(
|
|
459
|
+
self, *, limit: Optional[int] = None
|
|
460
|
+
) -> Iterator[models.PaymentTransaction]:
|
|
461
|
+
"""Every adjustment, page by page."""
|
|
462
|
+
return self._iterate(self._async.iter_adjustments(limit=limit))
|
|
463
|
+
|
|
464
|
+
def fees(
|
|
465
|
+
self, *, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
466
|
+
) -> Page[models.FeeTransaction]:
|
|
467
|
+
"""``GET /api/v1/portfolio/fees``."""
|
|
468
|
+
return self._run(self._async.fees(limit=limit, cursor=cursor))
|
|
469
|
+
|
|
470
|
+
def iter_fees(self, *, limit: Optional[int] = None) -> Iterator[models.FeeTransaction]:
|
|
471
|
+
"""Every fee entry, page by page."""
|
|
472
|
+
return self._iterate(self._async.iter_fees(limit=limit))
|
|
473
|
+
|
|
474
|
+
def loyalty(
|
|
475
|
+
self, *, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
476
|
+
) -> Page[models.Transaction]:
|
|
477
|
+
"""``GET /api/v1/portfolio/loyalty``."""
|
|
478
|
+
return self._run(self._async.loyalty(limit=limit, cursor=cursor))
|
|
479
|
+
|
|
480
|
+
def iter_loyalty(self, *, limit: Optional[int] = None) -> Iterator[models.Transaction]:
|
|
481
|
+
"""Every loyalty entry, page by page."""
|
|
482
|
+
return self._iterate(self._async.iter_loyalty(limit=limit))
|
|
483
|
+
|
|
484
|
+
# Terms ----------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
def accept_terms(
|
|
487
|
+
self,
|
|
488
|
+
device_id: str,
|
|
489
|
+
*,
|
|
490
|
+
accept_terms: bool = True,
|
|
491
|
+
accept_privacy: bool = True,
|
|
492
|
+
accept_house_rules: Optional[bool] = None,
|
|
493
|
+
) -> str:
|
|
494
|
+
"""``POST /api/v1/tnc/accept``."""
|
|
495
|
+
return self._run(
|
|
496
|
+
self._async.accept_terms(
|
|
497
|
+
device_id,
|
|
498
|
+
accept_terms=accept_terms,
|
|
499
|
+
accept_privacy=accept_privacy,
|
|
500
|
+
accept_house_rules=accept_house_rules,
|
|
501
|
+
)
|
|
502
|
+
)
|
stx/_config.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Where the SDK connects: (region, env) to a host, and host to URLs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Optional, Tuple, Union
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from stx.enums import Environment, Region
|
|
9
|
+
from stx.exceptions import STXConfigException
|
|
10
|
+
|
|
11
|
+
# (Region, Environment) -> hostname, for the environments published at
|
|
12
|
+
# https://docs.stxapp.io/environments/. United States production is absent
|
|
13
|
+
# until that host serves the API. Any other host, such as a local server, is
|
|
14
|
+
# reachable through ``host=``.
|
|
15
|
+
#
|
|
16
|
+
# API keys do not carry across exchanges: an Ontario key will not
|
|
17
|
+
# authenticate against the United States exchange.
|
|
18
|
+
HOSTS: Dict[Tuple[Region, Environment], str] = {
|
|
19
|
+
(Region.US, Environment.DEMO): "demo.stxapp.io",
|
|
20
|
+
(Region.ONTARIO, Environment.DEMO): "demo.stxapp.ca",
|
|
21
|
+
(Region.ONTARIO, Environment.PRODUCTION): "api.on.stxapp.ca",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Spellings accepted for ``env`` on top of the Environment values.
|
|
25
|
+
_ENV_ALIASES: Dict[str, Environment] = {
|
|
26
|
+
"prod": Environment.PRODUCTION,
|
|
27
|
+
"live": Environment.PRODUCTION,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
SOCKET_PATH = "/socket/websocket"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _coerce_region(value: Union[Region, str, None]) -> Optional[Region]:
|
|
34
|
+
if value is None or isinstance(value, Region):
|
|
35
|
+
return value
|
|
36
|
+
try:
|
|
37
|
+
return Region(value.lower())
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise STXConfigException(
|
|
40
|
+
f"Unknown region: {value!r}. Known: {[r.value for r in Region]}."
|
|
41
|
+
) from None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _coerce_env(value: Union[Environment, str, None]) -> Optional[Environment]:
|
|
45
|
+
if value is None or isinstance(value, Environment):
|
|
46
|
+
return value
|
|
47
|
+
lowered = value.lower()
|
|
48
|
+
if lowered in _ENV_ALIASES:
|
|
49
|
+
return _ENV_ALIASES[lowered]
|
|
50
|
+
try:
|
|
51
|
+
return Environment(lowered)
|
|
52
|
+
except ValueError:
|
|
53
|
+
raise STXConfigException(
|
|
54
|
+
f"Unknown env: {value!r}. Known: {[e.value for e in Environment]}."
|
|
55
|
+
) from None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_host(
|
|
59
|
+
region: Union[Region, str, None],
|
|
60
|
+
env: Union[Environment, str, None],
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Hostname for a (region, env) pair, or ``STXConfigException``."""
|
|
63
|
+
region_enum = _coerce_region(region)
|
|
64
|
+
env_enum = _coerce_env(env)
|
|
65
|
+
if region_enum is None or env_enum is None:
|
|
66
|
+
raise STXConfigException(
|
|
67
|
+
"No host configured. Pass region= and env= (for example "
|
|
68
|
+
"region='us', env='demo'), or host=, or set them in a profile "
|
|
69
|
+
"in ~/.stx/credentials."
|
|
70
|
+
)
|
|
71
|
+
host = HOSTS.get((region_enum, env_enum))
|
|
72
|
+
if host is None:
|
|
73
|
+
raise STXConfigException(
|
|
74
|
+
f"No host configured for region={region_enum.value}, env={env_enum.value}. "
|
|
75
|
+
"Pass host= explicitly."
|
|
76
|
+
)
|
|
77
|
+
return host
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def base_url_for(
|
|
81
|
+
region: Union[Region, str, None],
|
|
82
|
+
env: Union[Environment, str, None],
|
|
83
|
+
host: Optional[str],
|
|
84
|
+
) -> str:
|
|
85
|
+
"""The API base URL (scheme and authority, no path).
|
|
86
|
+
|
|
87
|
+
``host`` may be a bare hostname (``demo.stxapp.io``, ``localhost:4000``),
|
|
88
|
+
which gets ``https://``, or a URL (``http://localhost:4000``) whose
|
|
89
|
+
scheme is kept. Any path on the URL is ignored: the API lives at
|
|
90
|
+
``/api/v1`` on every host.
|
|
91
|
+
"""
|
|
92
|
+
if host:
|
|
93
|
+
host = host.strip()
|
|
94
|
+
if host.startswith(("http://", "https://")):
|
|
95
|
+
parsed = urlparse(host)
|
|
96
|
+
if not parsed.netloc:
|
|
97
|
+
raise STXConfigException(f"Cannot parse host {host!r}.")
|
|
98
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
99
|
+
return f"https://{host.rstrip('/')}"
|
|
100
|
+
return f"https://{resolve_host(region, env)}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def socket_url_for(base_url: str) -> str:
|
|
104
|
+
"""The WebSocket URL for an API base URL: https maps to wss, http to ws."""
|
|
105
|
+
parsed = urlparse(base_url)
|
|
106
|
+
scheme = "ws" if parsed.scheme == "http" else "wss"
|
|
107
|
+
return f"{scheme}://{parsed.netloc}{SOCKET_PATH}?vsn=2.0.0"
|