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/_http.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Request building and response decoding shared by the REST clients.
|
|
2
|
+
|
|
3
|
+
Nothing here does I/O. :func:`build_request` turns an operation id from
|
|
4
|
+
``stx/_operations.py`` plus arguments into the method, path-with-query and
|
|
5
|
+
JSON body that go on the wire; the clients sign exactly that path and send
|
|
6
|
+
exactly that URL, so the signature always covers the query string the
|
|
7
|
+
server sees.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from typing import Any, Dict, Iterable, Mapping, Optional, Tuple, Union
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
from stx._operations import OPERATIONS, Operation
|
|
19
|
+
from stx.exceptions import STXException, error_for_status
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PreparedRequest:
|
|
24
|
+
"""One REST call, ready to sign and send."""
|
|
25
|
+
|
|
26
|
+
operation_id: str
|
|
27
|
+
method: str
|
|
28
|
+
path: str # path plus "?query" when there is one; this is what gets signed
|
|
29
|
+
body: Optional[bytes]
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def idempotent(self) -> bool:
|
|
33
|
+
return self.method in ("GET", "DELETE")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _encode_value(value: Any) -> str:
|
|
37
|
+
# Multi-value filters are comma-separated, never repeated keys.
|
|
38
|
+
if isinstance(value, bool):
|
|
39
|
+
return "true" if value else "false"
|
|
40
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
41
|
+
return ",".join(_encode_value(v) for v in value)
|
|
42
|
+
return str(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def encode_query(params: Mapping[str, Any]) -> str:
|
|
46
|
+
"""``key=value&...`` for the params that are not ``None``, in the given order.
|
|
47
|
+
|
|
48
|
+
Lists become comma-separated values (``market_ids=a,b``); booleans
|
|
49
|
+
become ``true``/``false``. Keys and values are percent-encoded with
|
|
50
|
+
commas left literal, so the string is stable and readable in logs.
|
|
51
|
+
"""
|
|
52
|
+
parts = []
|
|
53
|
+
for key, value in params.items():
|
|
54
|
+
if value is None:
|
|
55
|
+
continue
|
|
56
|
+
if isinstance(value, (list, tuple, set, frozenset)) and not value:
|
|
57
|
+
continue
|
|
58
|
+
parts.append(f"{quote(key, safe='')}={quote(_encode_value(value), safe=',')}")
|
|
59
|
+
return "&".join(parts)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _json_default(value: Any) -> Any:
|
|
63
|
+
if isinstance(value, Decimal):
|
|
64
|
+
return str(value)
|
|
65
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_request(
|
|
69
|
+
operation_id: str,
|
|
70
|
+
*,
|
|
71
|
+
path_params: Optional[Mapping[str, str]] = None,
|
|
72
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
73
|
+
body: Any = None,
|
|
74
|
+
) -> PreparedRequest:
|
|
75
|
+
"""Prepare the call for ``operation_id``.
|
|
76
|
+
|
|
77
|
+
Raises ``ValueError`` for a query key the operation does not declare,
|
|
78
|
+
which catches a client method drifting away from the spec.
|
|
79
|
+
"""
|
|
80
|
+
op: Operation = OPERATIONS[operation_id]
|
|
81
|
+
path = op.path
|
|
82
|
+
for name in op.path_params:
|
|
83
|
+
value = (path_params or {}).get(name)
|
|
84
|
+
if not value:
|
|
85
|
+
raise ValueError(f"{name} is required")
|
|
86
|
+
path = path.replace("{" + name + "}", quote(str(value), safe=""))
|
|
87
|
+
query = dict(query or {})
|
|
88
|
+
unknown = set(query) - set(op.query)
|
|
89
|
+
if unknown:
|
|
90
|
+
raise ValueError(f"{operation_id} does not take {sorted(unknown)}")
|
|
91
|
+
qs = encode_query(query)
|
|
92
|
+
if qs:
|
|
93
|
+
path = f"{path}?{qs}"
|
|
94
|
+
payload: Optional[bytes] = None
|
|
95
|
+
if op.has_body:
|
|
96
|
+
payload = json.dumps(body if body is not None else {}, default=_json_default).encode(
|
|
97
|
+
"utf-8"
|
|
98
|
+
)
|
|
99
|
+
return PreparedRequest(operation_id, op.method, path, payload)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def decode_response(
|
|
103
|
+
request: PreparedRequest,
|
|
104
|
+
status_code: int,
|
|
105
|
+
content: bytes,
|
|
106
|
+
retry_after: Optional[str] = None,
|
|
107
|
+
) -> Any:
|
|
108
|
+
"""The parsed JSON body of a 2xx response, or raise the mapped exception."""
|
|
109
|
+
try:
|
|
110
|
+
body: Union[Dict[str, Any], str, None] = json.loads(content) if content else None
|
|
111
|
+
except ValueError:
|
|
112
|
+
body = content.decode("utf-8", errors="replace")
|
|
113
|
+
if 200 <= status_code < 300:
|
|
114
|
+
if not isinstance(body, dict):
|
|
115
|
+
raise STXException(
|
|
116
|
+
f"Expected a JSON object from {request.method} {request.path}, got {body!r:.200}",
|
|
117
|
+
status_code=status_code,
|
|
118
|
+
body=body,
|
|
119
|
+
method=request.method,
|
|
120
|
+
path=request.path,
|
|
121
|
+
)
|
|
122
|
+
return body
|
|
123
|
+
raise error_for_status(
|
|
124
|
+
status_code, body, method=request.method, path=request.path, retry_after=retry_after
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def split_page(operation_id: str, body: Dict[str, Any]) -> Tuple[Iterable[Any], Optional[str]]:
|
|
129
|
+
"""(items, next cursor) from a list response."""
|
|
130
|
+
op = OPERATIONS[operation_id]
|
|
131
|
+
return body.get(op.response_key) or [], body.get("cursor")
|
stx/_money.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Convert the two market-metadata channels to the SDK's string format.
|
|
2
|
+
|
|
3
|
+
Every channel the SDK exposes sends money as dollar strings (``"0.5600"``)
|
|
4
|
+
and quantities as quantity strings (``"2.00"``), the same as REST, except
|
|
5
|
+
two: ``markets`` and ``market_updates`` still send JSON numbers, with
|
|
6
|
+
prices in cents. The SDK rewrites those fields so every amount it hands
|
|
7
|
+
you is a string in one format. The field lists come from the channel
|
|
8
|
+
reference pages (MarketPayload fields):
|
|
9
|
+
|
|
10
|
+
Prices, cents to a dollar string with at least four decimals:
|
|
11
|
+
``price``, ``last_traded_price``, ``max_price``, ``price`` inside each
|
|
12
|
+
``bids``, ``offers`` and ``recent_trades`` entry, and ``from``, ``to``
|
|
13
|
+
and ``inc`` inside each ``order_price_rules`` entry.
|
|
14
|
+
|
|
15
|
+
Contract counts, number to a quantity string with at least two decimals:
|
|
16
|
+
``total_volume``, ``volume_24h``, ``open_interest``, and ``quantity``
|
|
17
|
+
inside each ``bids``, ``offers`` and ``recent_trades`` entry.
|
|
18
|
+
|
|
19
|
+
Left as numbers, because they are not money or contracts:
|
|
20
|
+
``probability``, ``price_change_24h`` (a percentage),
|
|
21
|
+
``in_play_delay_sec``, ``unix_timestamp``, ``event_start``.
|
|
22
|
+
|
|
23
|
+
Values that are already strings (a server that has moved these feeds to the
|
|
24
|
+
dollar format) pass through unchanged, and ``null`` stays ``None``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from decimal import Decimal, InvalidOperation
|
|
30
|
+
from typing import Any, Dict, Optional
|
|
31
|
+
|
|
32
|
+
_CENTS = Decimal(100)
|
|
33
|
+
|
|
34
|
+
PRICE_FIELDS = ("price", "last_traded_price", "max_price")
|
|
35
|
+
QUANTITY_FIELDS = ("total_volume", "volume_24h", "open_interest")
|
|
36
|
+
LEVEL_LISTS = ("bids", "offers", "recent_trades")
|
|
37
|
+
PRICE_RULE_FIELDS = ("from", "to", "inc")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _fmt(value: Decimal, min_places: int) -> str:
|
|
41
|
+
# At least ``min_places`` decimals, more if the value carries them:
|
|
42
|
+
# the same rule the API uses for its own strings.
|
|
43
|
+
exponent = value.normalize().as_tuple().exponent
|
|
44
|
+
places = max(min_places, -exponent if isinstance(exponent, int) else 0)
|
|
45
|
+
return f"{value:.{places}f}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _to_decimal(value: Any) -> Optional[Decimal]:
|
|
49
|
+
if value is None or isinstance(value, bool):
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
return Decimal(str(value))
|
|
53
|
+
except InvalidOperation:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cents_to_dollars(value: Any) -> Any:
|
|
58
|
+
"""``56`` to ``"0.5600"``; ``1.0e4`` to ``"100.0000"``. Strings and
|
|
59
|
+
``None`` pass through."""
|
|
60
|
+
if value is None or isinstance(value, str):
|
|
61
|
+
return value
|
|
62
|
+
dec = _to_decimal(value)
|
|
63
|
+
if dec is None:
|
|
64
|
+
return value
|
|
65
|
+
return _fmt(dec / _CENTS, 4)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def to_quantity(value: Any) -> Any:
|
|
69
|
+
"""``2`` to ``"2.00"``. Strings and ``None`` pass through."""
|
|
70
|
+
if value is None or isinstance(value, str):
|
|
71
|
+
return value
|
|
72
|
+
dec = _to_decimal(value)
|
|
73
|
+
if dec is None:
|
|
74
|
+
return value
|
|
75
|
+
return _fmt(dec, 2)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def convert_market_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
79
|
+
"""One market object from ``markets`` or ``market_updates``, converted.
|
|
80
|
+
|
|
81
|
+
Returns a new dict; the input is not modified. Fields that are absent
|
|
82
|
+
stay absent, which matters for ``market_updated`` deltas.
|
|
83
|
+
"""
|
|
84
|
+
out = dict(payload)
|
|
85
|
+
for key in PRICE_FIELDS:
|
|
86
|
+
if key in out:
|
|
87
|
+
out[key] = cents_to_dollars(out[key])
|
|
88
|
+
for key in QUANTITY_FIELDS:
|
|
89
|
+
if key in out:
|
|
90
|
+
out[key] = to_quantity(out[key])
|
|
91
|
+
for key in LEVEL_LISTS:
|
|
92
|
+
levels = out.get(key)
|
|
93
|
+
if isinstance(levels, list):
|
|
94
|
+
converted = []
|
|
95
|
+
for level in levels:
|
|
96
|
+
if isinstance(level, dict):
|
|
97
|
+
level = dict(level)
|
|
98
|
+
if "price" in level:
|
|
99
|
+
level["price"] = cents_to_dollars(level["price"])
|
|
100
|
+
if "quantity" in level:
|
|
101
|
+
level["quantity"] = to_quantity(level["quantity"])
|
|
102
|
+
converted.append(level)
|
|
103
|
+
out[key] = converted
|
|
104
|
+
rules = out.get("order_price_rules")
|
|
105
|
+
if isinstance(rules, list):
|
|
106
|
+
out["order_price_rules"] = [
|
|
107
|
+
{k: (cents_to_dollars(v) if k in PRICE_RULE_FIELDS else v) for k, v in rule.items()}
|
|
108
|
+
if isinstance(rule, dict)
|
|
109
|
+
else rule
|
|
110
|
+
for rule in rules
|
|
111
|
+
]
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def convert_markets_frame(payload: Any) -> Any:
|
|
116
|
+
"""A ``markets`` channel push: a map of market id to market object."""
|
|
117
|
+
if not isinstance(payload, dict):
|
|
118
|
+
return payload
|
|
119
|
+
return {
|
|
120
|
+
market_id: convert_market_payload(market) if isinstance(market, dict) else market
|
|
121
|
+
for market_id, market in payload.items()
|
|
122
|
+
}
|
stx/_operations.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""REST operations, one per (method, path) in the vendored spec.
|
|
2
|
+
|
|
3
|
+
Generated by ``python -m tools.generate_models`` from
|
|
4
|
+
``spec/openapi.json`` (source commit ``6a6ca2731ac6870f65999f86c857a5e6a28b1f5b``).
|
|
5
|
+
Do not edit by hand. The clients look up method, path and
|
|
6
|
+
response envelope here, and the unit tests assert every
|
|
7
|
+
operation has a client method.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Dict, NamedTuple, Tuple
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Operation(NamedTuple):
|
|
16
|
+
method: str
|
|
17
|
+
path: str
|
|
18
|
+
query: Tuple[str, ...]
|
|
19
|
+
path_params: Tuple[str, ...]
|
|
20
|
+
has_body: bool
|
|
21
|
+
response_key: str
|
|
22
|
+
model: str
|
|
23
|
+
is_list: bool
|
|
24
|
+
paginated: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
OPERATIONS: Dict[str, Operation] = {
|
|
28
|
+
'account_balance_get': Operation(
|
|
29
|
+
method='GET',
|
|
30
|
+
path='/api/v1/account/balance',
|
|
31
|
+
query=(),
|
|
32
|
+
path_params=(),
|
|
33
|
+
has_body=False,
|
|
34
|
+
response_key='balance',
|
|
35
|
+
model='Balance',
|
|
36
|
+
is_list=False,
|
|
37
|
+
paginated=False,
|
|
38
|
+
),
|
|
39
|
+
'account_market_stats_get': Operation(
|
|
40
|
+
method='GET',
|
|
41
|
+
path='/api/v1/account/market_stats',
|
|
42
|
+
query=('market_ids', 'event_ids', 'exclude_zero_settlements', 'from_time', 'to_time', 'sports', 'competitions', 'limit', 'cursor'),
|
|
43
|
+
path_params=(),
|
|
44
|
+
has_body=False,
|
|
45
|
+
response_key='market_stats',
|
|
46
|
+
model='MarketStat',
|
|
47
|
+
is_list=True,
|
|
48
|
+
paginated=True,
|
|
49
|
+
),
|
|
50
|
+
'events_get': Operation(
|
|
51
|
+
method='GET',
|
|
52
|
+
path='/api/v1/events',
|
|
53
|
+
query=('event_ids', 'sports', 'competitions', 'event_types', 'title', 'status', 'promoted', 'sort_by[name]', 'sort_by[direction]', 'limit', 'cursor'),
|
|
54
|
+
path_params=(),
|
|
55
|
+
has_body=False,
|
|
56
|
+
response_key='events',
|
|
57
|
+
model='Event',
|
|
58
|
+
is_list=True,
|
|
59
|
+
paginated=True,
|
|
60
|
+
),
|
|
61
|
+
'fills_get': Operation(
|
|
62
|
+
method='GET',
|
|
63
|
+
path='/api/v1/fills',
|
|
64
|
+
query=('market_ids', 'order_ids', 'status', 'limit', 'cursor'),
|
|
65
|
+
path_params=(),
|
|
66
|
+
has_body=False,
|
|
67
|
+
response_key='fills',
|
|
68
|
+
model='Fill',
|
|
69
|
+
is_list=True,
|
|
70
|
+
paginated=True,
|
|
71
|
+
),
|
|
72
|
+
'markets_get': Operation(
|
|
73
|
+
method='GET',
|
|
74
|
+
path='/api/v1/markets',
|
|
75
|
+
query=('market_ids', 'event_ids', 'status', 'trading', 'sports', 'competitions', 'sort_by[name]', 'sort_by[direction]', 'limit', 'cursor'),
|
|
76
|
+
path_params=(),
|
|
77
|
+
has_body=False,
|
|
78
|
+
response_key='markets',
|
|
79
|
+
model='Market',
|
|
80
|
+
is_list=True,
|
|
81
|
+
paginated=True,
|
|
82
|
+
),
|
|
83
|
+
'me_get': Operation(
|
|
84
|
+
method='GET',
|
|
85
|
+
path='/api/v1/me',
|
|
86
|
+
query=(),
|
|
87
|
+
path_params=(),
|
|
88
|
+
has_body=False,
|
|
89
|
+
response_key='me',
|
|
90
|
+
model='Me',
|
|
91
|
+
is_list=False,
|
|
92
|
+
paginated=False,
|
|
93
|
+
),
|
|
94
|
+
'orders_get': Operation(
|
|
95
|
+
method='GET',
|
|
96
|
+
path='/api/v1/orders',
|
|
97
|
+
query=('order_ids', 'client_order_ids', 'market_ids', 'status', 'limit', 'cursor'),
|
|
98
|
+
path_params=(),
|
|
99
|
+
has_body=False,
|
|
100
|
+
response_key='orders',
|
|
101
|
+
model='Order',
|
|
102
|
+
is_list=True,
|
|
103
|
+
paginated=True,
|
|
104
|
+
),
|
|
105
|
+
'orders_post': Operation(
|
|
106
|
+
method='POST',
|
|
107
|
+
path='/api/v1/orders',
|
|
108
|
+
query=(),
|
|
109
|
+
path_params=(),
|
|
110
|
+
has_body=True,
|
|
111
|
+
response_key='order',
|
|
112
|
+
model='Order',
|
|
113
|
+
is_list=False,
|
|
114
|
+
paginated=False,
|
|
115
|
+
),
|
|
116
|
+
'orders_all_delete': Operation(
|
|
117
|
+
method='DELETE',
|
|
118
|
+
path='/api/v1/orders/all',
|
|
119
|
+
query=(),
|
|
120
|
+
path_params=(),
|
|
121
|
+
has_body=False,
|
|
122
|
+
response_key='cancellations',
|
|
123
|
+
model='Cancellation',
|
|
124
|
+
is_list=True,
|
|
125
|
+
paginated=False,
|
|
126
|
+
),
|
|
127
|
+
'orders_batched_delete': Operation(
|
|
128
|
+
method='DELETE',
|
|
129
|
+
path='/api/v1/orders/batched',
|
|
130
|
+
query=(),
|
|
131
|
+
path_params=(),
|
|
132
|
+
has_body=True,
|
|
133
|
+
response_key='cancellations',
|
|
134
|
+
model='Cancellation',
|
|
135
|
+
is_list=True,
|
|
136
|
+
paginated=False,
|
|
137
|
+
),
|
|
138
|
+
'orders_batched_post': Operation(
|
|
139
|
+
method='POST',
|
|
140
|
+
path='/api/v1/orders/batched',
|
|
141
|
+
query=(),
|
|
142
|
+
path_params=(),
|
|
143
|
+
has_body=True,
|
|
144
|
+
response_key='results',
|
|
145
|
+
model='',
|
|
146
|
+
is_list=True,
|
|
147
|
+
paginated=False,
|
|
148
|
+
),
|
|
149
|
+
'orders__order_id_delete': Operation(
|
|
150
|
+
method='DELETE',
|
|
151
|
+
path='/api/v1/orders/{order_id}',
|
|
152
|
+
query=(),
|
|
153
|
+
path_params=('order_id',),
|
|
154
|
+
has_body=False,
|
|
155
|
+
response_key='',
|
|
156
|
+
model='',
|
|
157
|
+
is_list=False,
|
|
158
|
+
paginated=False,
|
|
159
|
+
),
|
|
160
|
+
'orders__id_get': Operation(
|
|
161
|
+
method='GET',
|
|
162
|
+
path='/api/v1/orders/{order_id}',
|
|
163
|
+
query=(),
|
|
164
|
+
path_params=('order_id',),
|
|
165
|
+
has_body=False,
|
|
166
|
+
response_key='order',
|
|
167
|
+
model='Order',
|
|
168
|
+
is_list=False,
|
|
169
|
+
paginated=False,
|
|
170
|
+
),
|
|
171
|
+
'portfolio_adjustments_get': Operation(
|
|
172
|
+
method='GET',
|
|
173
|
+
path='/api/v1/portfolio/adjustments',
|
|
174
|
+
query=('limit', 'cursor'),
|
|
175
|
+
path_params=(),
|
|
176
|
+
has_body=False,
|
|
177
|
+
response_key='adjustments',
|
|
178
|
+
model='PaymentTransaction',
|
|
179
|
+
is_list=True,
|
|
180
|
+
paginated=True,
|
|
181
|
+
),
|
|
182
|
+
'portfolio_deposits_get': Operation(
|
|
183
|
+
method='GET',
|
|
184
|
+
path='/api/v1/portfolio/deposits',
|
|
185
|
+
query=('limit', 'cursor'),
|
|
186
|
+
path_params=(),
|
|
187
|
+
has_body=False,
|
|
188
|
+
response_key='deposits',
|
|
189
|
+
model='PaymentTransaction',
|
|
190
|
+
is_list=True,
|
|
191
|
+
paginated=True,
|
|
192
|
+
),
|
|
193
|
+
'portfolio_fees_get': Operation(
|
|
194
|
+
method='GET',
|
|
195
|
+
path='/api/v1/portfolio/fees',
|
|
196
|
+
query=('limit', 'cursor'),
|
|
197
|
+
path_params=(),
|
|
198
|
+
has_body=False,
|
|
199
|
+
response_key='fees',
|
|
200
|
+
model='FeeTransaction',
|
|
201
|
+
is_list=True,
|
|
202
|
+
paginated=True,
|
|
203
|
+
),
|
|
204
|
+
'portfolio_loyalty_get': Operation(
|
|
205
|
+
method='GET',
|
|
206
|
+
path='/api/v1/portfolio/loyalty',
|
|
207
|
+
query=('limit', 'cursor'),
|
|
208
|
+
path_params=(),
|
|
209
|
+
has_body=False,
|
|
210
|
+
response_key='loyalty',
|
|
211
|
+
model='Transaction',
|
|
212
|
+
is_list=True,
|
|
213
|
+
paginated=True,
|
|
214
|
+
),
|
|
215
|
+
'portfolio_settlements_get': Operation(
|
|
216
|
+
method='GET',
|
|
217
|
+
path='/api/v1/portfolio/settlements',
|
|
218
|
+
query=('market_ids', 'type', 'limit', 'cursor'),
|
|
219
|
+
path_params=(),
|
|
220
|
+
has_body=False,
|
|
221
|
+
response_key='settlements',
|
|
222
|
+
model='Settlement',
|
|
223
|
+
is_list=True,
|
|
224
|
+
paginated=True,
|
|
225
|
+
),
|
|
226
|
+
'portfolio_withdrawals_get': Operation(
|
|
227
|
+
method='GET',
|
|
228
|
+
path='/api/v1/portfolio/withdrawals',
|
|
229
|
+
query=('limit', 'cursor'),
|
|
230
|
+
path_params=(),
|
|
231
|
+
has_body=False,
|
|
232
|
+
response_key='withdrawals',
|
|
233
|
+
model='PaymentTransaction',
|
|
234
|
+
is_list=True,
|
|
235
|
+
paginated=True,
|
|
236
|
+
),
|
|
237
|
+
'positions_get': Operation(
|
|
238
|
+
method='GET',
|
|
239
|
+
path='/api/v1/positions',
|
|
240
|
+
query=('market_ids',),
|
|
241
|
+
path_params=(),
|
|
242
|
+
has_body=False,
|
|
243
|
+
response_key='positions',
|
|
244
|
+
model='Position',
|
|
245
|
+
is_list=True,
|
|
246
|
+
paginated=False,
|
|
247
|
+
),
|
|
248
|
+
'tnc_accept_post': Operation(
|
|
249
|
+
method='POST',
|
|
250
|
+
path='/api/v1/tnc/accept',
|
|
251
|
+
query=(),
|
|
252
|
+
path_params=(),
|
|
253
|
+
has_body=True,
|
|
254
|
+
response_key='message',
|
|
255
|
+
model='',
|
|
256
|
+
is_list=False,
|
|
257
|
+
paginated=False,
|
|
258
|
+
),
|
|
259
|
+
}
|
stx/_paging.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""One page of a list endpoint.
|
|
2
|
+
|
|
3
|
+
Every list endpoint pages by cursor: the response carries ``cursor``, an
|
|
4
|
+
opaque string to pass back for the next page, or ``None`` on the last one.
|
|
5
|
+
A list method returns a :class:`Page`, which behaves like a list of models
|
|
6
|
+
and carries that cursor::
|
|
7
|
+
|
|
8
|
+
page = await client.markets(status="open", limit=50)
|
|
9
|
+
for market in page:
|
|
10
|
+
print(market.symbol, market.last_traded_price)
|
|
11
|
+
if page.cursor:
|
|
12
|
+
page = await client.markets(status="open", limit=50, cursor=page.cursor)
|
|
13
|
+
|
|
14
|
+
To walk every page, use the matching ``iter_`` method, which follows the
|
|
15
|
+
cursor for you::
|
|
16
|
+
|
|
17
|
+
async for market in client.iter_markets(status="open"):
|
|
18
|
+
...
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Generic, Iterator, List, Optional, Sequence, TypeVar, overload
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Page(Sequence[T], Generic[T]):
|
|
29
|
+
"""A ``Sequence`` of models plus the ``cursor`` for the next page."""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("cursor", "items")
|
|
32
|
+
|
|
33
|
+
def __init__(self, items: List[T], cursor: Optional[str]) -> None:
|
|
34
|
+
self.items = items
|
|
35
|
+
self.cursor = cursor
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def has_more(self) -> bool:
|
|
39
|
+
"""``True`` when another page exists."""
|
|
40
|
+
return bool(self.cursor)
|
|
41
|
+
|
|
42
|
+
def __len__(self) -> int:
|
|
43
|
+
return len(self.items)
|
|
44
|
+
|
|
45
|
+
@overload
|
|
46
|
+
def __getitem__(self, idx: int) -> T: ...
|
|
47
|
+
|
|
48
|
+
@overload
|
|
49
|
+
def __getitem__(self, idx: slice) -> List[T]: ...
|
|
50
|
+
|
|
51
|
+
def __getitem__(self, idx):
|
|
52
|
+
return self.items[idx]
|
|
53
|
+
|
|
54
|
+
def __iter__(self) -> Iterator[T]:
|
|
55
|
+
return iter(self.items)
|
|
56
|
+
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
more = ", more" if self.has_more else ""
|
|
59
|
+
return f"Page({len(self.items)} items{more})"
|
stx/_results.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Result types for calls whose response is not a single spec schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from stx._base import STXModel
|
|
10
|
+
from stx.models import Order
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BatchOrderResult(STXModel):
|
|
14
|
+
"""One entry of ``place_orders``: the order, or why it was not placed.
|
|
15
|
+
|
|
16
|
+
Results come back in the order the orders were sent. Exactly one of
|
|
17
|
+
``order`` and ``errors`` is set.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
order: Optional[Order] = Field(None, description="The placed order.")
|
|
21
|
+
errors: Optional[List[str]] = Field(None, description="Why this order was not placed.")
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def ok(self) -> bool:
|
|
25
|
+
"""``True`` when the order was placed."""
|
|
26
|
+
return self.order is not None and not self.errors
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
BatchOrderResult.model_rebuild()
|
stx/_retry.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Retry policy for REST calls.
|
|
2
|
+
|
|
3
|
+
Which failures are retried:
|
|
4
|
+
|
|
5
|
+
* ``STXRateLimitException`` (429), on every method. The server refused the
|
|
6
|
+
request before acting on it, so sending it again is safe. A
|
|
7
|
+
``Retry-After`` header sets the wait.
|
|
8
|
+
* ``STXServerException`` (5xx) and ``STXTransportException`` (connection
|
|
9
|
+
reset, timeout), on ``GET`` and ``DELETE`` only.
|
|
10
|
+
|
|
11
|
+
``POST`` is not retried after a 5xx or a dropped connection, because the
|
|
12
|
+
order may already be on the book: placing it again could double your
|
|
13
|
+
position. Look it up with ``orders(client_order_ids=[...])`` instead, which
|
|
14
|
+
is what ``client_order_id`` is for.
|
|
15
|
+
|
|
16
|
+
Nothing else is retried: a 400, 401, 403, 404 or 422 will fail the same
|
|
17
|
+
way the second time.
|
|
18
|
+
|
|
19
|
+
Backoff is exponential with jitter, capped at ``max_backoff``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import random
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Tuple, Type
|
|
27
|
+
|
|
28
|
+
from stx.exceptions import (
|
|
29
|
+
STXException,
|
|
30
|
+
STXRateLimitException,
|
|
31
|
+
STXServerException,
|
|
32
|
+
STXTransportException,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_DEFAULT_RETRYABLE: Tuple[Type[STXException], ...] = (
|
|
36
|
+
STXServerException,
|
|
37
|
+
STXTransportException,
|
|
38
|
+
STXRateLimitException,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class RetryPolicy:
|
|
44
|
+
"""How many times to try a call, and how long to wait between tries.
|
|
45
|
+
|
|
46
|
+
The default is 3 attempts, 0.5 s initial backoff doubling to a 30 s cap,
|
|
47
|
+
with jitter. ``RetryPolicy(max_attempts=1)`` (or ``stx.NO_RETRY``) turns
|
|
48
|
+
retries off.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
max_attempts: int = 3
|
|
52
|
+
initial_backoff: float = 0.5
|
|
53
|
+
max_backoff: float = 30.0
|
|
54
|
+
jitter: bool = True
|
|
55
|
+
retryable_exceptions: Tuple[Type[STXException], ...] = field(
|
|
56
|
+
default_factory=lambda: _DEFAULT_RETRYABLE
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def should_retry(self, exc: STXException, attempt: int, idempotent: bool) -> bool:
|
|
60
|
+
"""Whether to try again after ``exc`` ended attempt number ``attempt``."""
|
|
61
|
+
if attempt >= self.max_attempts:
|
|
62
|
+
return False
|
|
63
|
+
if not isinstance(exc, self.retryable_exceptions):
|
|
64
|
+
return False
|
|
65
|
+
# A 429 was refused before it was acted on; anything else may have
|
|
66
|
+
# been acted on, so only repeat it when repeating is harmless.
|
|
67
|
+
return idempotent or isinstance(exc, STXRateLimitException)
|
|
68
|
+
|
|
69
|
+
def compute_backoff(self, attempt: int, exc: STXException) -> float:
|
|
70
|
+
"""Seconds to wait after attempt ``attempt`` (1-based) failed."""
|
|
71
|
+
if isinstance(exc, STXRateLimitException) and exc.retry_after is not None:
|
|
72
|
+
return min(float(exc.retry_after), self.max_backoff)
|
|
73
|
+
capped = min(self.initial_backoff * (2 ** (attempt - 1)), self.max_backoff)
|
|
74
|
+
if self.jitter:
|
|
75
|
+
return capped * (0.5 + random.random() * 0.5)
|
|
76
|
+
return capped
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
NO_RETRY = RetryPolicy(max_attempts=1)
|
|
80
|
+
"""One attempt, no backoff."""
|