upside-python-sdk 0.1.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.
- upside/__init__.py +42 -0
- upside/api.py +61 -0
- upside/exchange.py +426 -0
- upside/info.py +157 -0
- upside/py.typed +0 -0
- upside/utils/__init__.py +1 -0
- upside/utils/constants.py +49 -0
- upside/utils/error.py +51 -0
- upside/utils/signing.py +172 -0
- upside/utils/types.py +133 -0
- upside/websocket_manager.py +199 -0
- upside_python_sdk-0.1.0.dist-info/METADATA +205 -0
- upside_python_sdk-0.1.0.dist-info/RECORD +15 -0
- upside_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- upside_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
upside/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Upside Python SDK — REST (`/info`, `/exchange`) and WebSocket client.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from upside import Info, Exchange
|
|
6
|
+
from upside.utils import constants
|
|
7
|
+
|
|
8
|
+
info = Info(base_url=constants.QA_API_URL)
|
|
9
|
+
print(info.configs())
|
|
10
|
+
|
|
11
|
+
exchange = Exchange(private_key, base_url=constants.QA_API_URL)
|
|
12
|
+
exchange.order(asset=1, is_buy=True, size="10", price="50")
|
|
13
|
+
|
|
14
|
+
See https://docs.upsidemax.xyz for the full API reference.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .api import API
|
|
18
|
+
from .exchange import Exchange
|
|
19
|
+
from .info import Info
|
|
20
|
+
from .utils import constants
|
|
21
|
+
from .utils.error import APIError, ClientError, ServerError, UpsideError, WebsocketError
|
|
22
|
+
from .utils.signing import NonceManager
|
|
23
|
+
from .utils.types import Cloid
|
|
24
|
+
from .websocket_manager import WebsocketManager
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"API",
|
|
30
|
+
"Info",
|
|
31
|
+
"Exchange",
|
|
32
|
+
"WebsocketManager",
|
|
33
|
+
"NonceManager",
|
|
34
|
+
"Cloid",
|
|
35
|
+
"constants",
|
|
36
|
+
"UpsideError",
|
|
37
|
+
"APIError",
|
|
38
|
+
"ClientError",
|
|
39
|
+
"ServerError",
|
|
40
|
+
"WebsocketError",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
upside/api.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Base HTTP transport shared by :class:`~upside.info.Info` and
|
|
2
|
+
:class:`~upside.exchange.Exchange`.
|
|
3
|
+
|
|
4
|
+
Everything is a JSON ``POST`` to ``/info`` or ``/exchange`` over one persistent
|
|
5
|
+
session. Gateway-level failures (non-2xx, or a body with ``"status": "error"``)
|
|
6
|
+
raise :class:`~upside.utils.error.ClientError` / ``ServerError``; business-level
|
|
7
|
+
outcomes (per-order ``statuses[].error``, non-zero ``errorCode``) are returned
|
|
8
|
+
verbatim for the caller to inspect.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from json import JSONDecodeError
|
|
13
|
+
from typing import Any, Optional, cast
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .utils import constants
|
|
18
|
+
from .utils.error import ClientError, ServerError
|
|
19
|
+
from .utils.types import Json
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class API:
|
|
23
|
+
"""Thin POST-only client with status-based exception mapping."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, base_url: Optional[str] = None, timeout: Optional[float] = None) -> None:
|
|
26
|
+
self.base_url = (base_url or constants.QA_API_URL).rstrip("/")
|
|
27
|
+
self.timeout = timeout
|
|
28
|
+
self.session = requests.Session()
|
|
29
|
+
self.session.headers.update({"Content-Type": "application/json"})
|
|
30
|
+
self._logger = logging.getLogger("upside")
|
|
31
|
+
|
|
32
|
+
def post(self, url_path: str, payload: Optional[Any] = None) -> Json:
|
|
33
|
+
"""POST ``payload`` to ``url_path`` and return the parsed JSON body."""
|
|
34
|
+
url = self.base_url + url_path
|
|
35
|
+
response = self.session.post(url, json=payload or {}, timeout=self.timeout)
|
|
36
|
+
return self._handle_response(response)
|
|
37
|
+
|
|
38
|
+
def _handle_response(self, response: requests.Response) -> Json:
|
|
39
|
+
try:
|
|
40
|
+
body = response.json()
|
|
41
|
+
except (JSONDecodeError, ValueError):
|
|
42
|
+
body = None
|
|
43
|
+
|
|
44
|
+
if response.ok and not (isinstance(body, dict) and body.get("status") == "error"):
|
|
45
|
+
if body is None:
|
|
46
|
+
raise ServerError(response.status_code, message=f"non-JSON response: {response.text[:200]}")
|
|
47
|
+
return cast(Json, body)
|
|
48
|
+
|
|
49
|
+
code = message = request_id = None
|
|
50
|
+
if isinstance(body, dict):
|
|
51
|
+
code = body.get("code")
|
|
52
|
+
message = body.get("message")
|
|
53
|
+
request_id = body.get("requestId")
|
|
54
|
+
else:
|
|
55
|
+
message = response.text[:500] or None
|
|
56
|
+
|
|
57
|
+
error_cls = ClientError if 400 <= response.status_code < 500 else ServerError
|
|
58
|
+
raise error_cls(response.status_code, code=code, message=message, request_id=request_id)
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
self.session.close()
|
upside/exchange.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Signed ``POST /exchange`` actions: orders, cancels, margin, agents, collateral.
|
|
2
|
+
|
|
3
|
+
Each method builds the action dict, signs it (Agent or Typed EIP-712 path,
|
|
4
|
+
chosen automatically by action type), wraps it in the
|
|
5
|
+
``{action, signature, nonce}`` envelope, and POSTs it. Prices and sizes are
|
|
6
|
+
**raw integer strings** — scale them with the contract's ``priceScale`` /
|
|
7
|
+
``qtyScale`` from :meth:`Info.configs`.
|
|
8
|
+
|
|
9
|
+
Order placement is asynchronous: a batch returns ``{"status": "accepted",
|
|
10
|
+
"response": {"type": "order", "data": {"count": n}}}``. Observe resting/filled
|
|
11
|
+
state via :meth:`Info.user_orders` or the ``orderUpdates`` / ``userFills``
|
|
12
|
+
WebSocket channels. Cancels, modifies, and margin actions respond
|
|
13
|
+
synchronously. See https://docs.upsidemax.xyz/exchange/overview.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import secrets
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
18
|
+
|
|
19
|
+
from eth_account import Account
|
|
20
|
+
|
|
21
|
+
from .api import API
|
|
22
|
+
from .utils import constants
|
|
23
|
+
from .utils.signing import NonceManager, Signature, Wallet, sign_action, to_wallet
|
|
24
|
+
from .utils.types import (
|
|
25
|
+
CancelByCloidRequest,
|
|
26
|
+
CancelRequest,
|
|
27
|
+
Cloid,
|
|
28
|
+
Json,
|
|
29
|
+
OrderRequest,
|
|
30
|
+
Tif,
|
|
31
|
+
as_cloid_str,
|
|
32
|
+
cloid_str,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Exchange(API):
|
|
37
|
+
"""Client for every state-changing operation, signed by ``wallet``.
|
|
38
|
+
|
|
39
|
+
``wallet`` may be a private-key hex string or an ``eth_account`` LocalAccount.
|
|
40
|
+
For delegated trading, sign with an agent key here while ``account_id`` still
|
|
41
|
+
refers to the master account (the server routes by recovered signer).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
wallet: Union[Wallet, str],
|
|
47
|
+
base_url: Optional[str] = None,
|
|
48
|
+
timeout: Optional[float] = None,
|
|
49
|
+
account_id: Optional[Union[int, str]] = None,
|
|
50
|
+
nonce_manager: Optional[NonceManager] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
super().__init__(base_url, timeout)
|
|
53
|
+
self.wallet: Wallet = to_wallet(wallet)
|
|
54
|
+
self.address: str = self.wallet.address.lower()
|
|
55
|
+
self.account_id = str(account_id) if account_id is not None else None
|
|
56
|
+
self.nonce_manager = nonce_manager or NonceManager()
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------ #
|
|
59
|
+
# Account
|
|
60
|
+
# ------------------------------------------------------------------ #
|
|
61
|
+
def register_account(
|
|
62
|
+
self,
|
|
63
|
+
invite_code: Optional[str] = None,
|
|
64
|
+
address: Optional[str] = None,
|
|
65
|
+
) -> Dict[str, Any]:
|
|
66
|
+
"""Register the wallet and receive an ``accountId`` (typed path).
|
|
67
|
+
|
|
68
|
+
``invite_code`` is required in gated environments (QA) and is sent
|
|
69
|
+
unsigned at the envelope top level. On QA a successful registration
|
|
70
|
+
triggers a 10,000 USDC test airdrop within ~10s.
|
|
71
|
+
"""
|
|
72
|
+
action = {"type": "registerAccount", "address": (address or self.address).lower()}
|
|
73
|
+
extra = {"inviteCode": invite_code} if invite_code is not None else None
|
|
74
|
+
result = self._post_action(action, extra)
|
|
75
|
+
if isinstance(result, dict):
|
|
76
|
+
account_id = result.get("response", {}).get("accountId")
|
|
77
|
+
if account_id is not None:
|
|
78
|
+
self.account_id = str(account_id)
|
|
79
|
+
return self._as_dict(result)
|
|
80
|
+
|
|
81
|
+
def enroll_user_to_market_deployer(self, market_deployer_id: int) -> Dict[str, Any]:
|
|
82
|
+
"""Enroll in an additional market deployer (idempotent)."""
|
|
83
|
+
return self._post_dict({"type": "enrollUserToMarketDeployer", "marketDeployerId": market_deployer_id})
|
|
84
|
+
|
|
85
|
+
def approve_agent(
|
|
86
|
+
self,
|
|
87
|
+
agent_address: Optional[str] = None,
|
|
88
|
+
agent_name: str = "",
|
|
89
|
+
valid_until: int = 0,
|
|
90
|
+
) -> Tuple[Dict[str, Any], Optional[str]]:
|
|
91
|
+
"""Authorize an agent wallet to sign trades (typed, master-only).
|
|
92
|
+
|
|
93
|
+
If ``agent_address`` is omitted, a fresh key is generated; the returned
|
|
94
|
+
tuple is ``(response, agent_private_key)`` so you can construct an agent
|
|
95
|
+
:class:`Exchange`. When an address is supplied, the second element is
|
|
96
|
+
``None``. ``valid_until`` is Unix ms (``0`` = permanent).
|
|
97
|
+
"""
|
|
98
|
+
agent_key: Optional[str] = None
|
|
99
|
+
if agent_address is None:
|
|
100
|
+
agent_key = "0x" + secrets.token_hex(32)
|
|
101
|
+
agent_address = Account.from_key(agent_key).address.lower()
|
|
102
|
+
action = {
|
|
103
|
+
"type": "approveAgent",
|
|
104
|
+
"agentAddress": agent_address.lower(),
|
|
105
|
+
"agentName": agent_name,
|
|
106
|
+
"validUntil": valid_until,
|
|
107
|
+
}
|
|
108
|
+
return self._post_dict(action), agent_key
|
|
109
|
+
|
|
110
|
+
def revoke_agent(self, agent_address: str) -> Dict[str, Any]:
|
|
111
|
+
"""Revoke a previously approved agent (typed, master-only)."""
|
|
112
|
+
return self._post_dict({"type": "revokeAgent", "agentAddress": agent_address.lower()})
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------ #
|
|
115
|
+
# Orders
|
|
116
|
+
# ------------------------------------------------------------------ #
|
|
117
|
+
def order(
|
|
118
|
+
self,
|
|
119
|
+
asset: int,
|
|
120
|
+
is_buy: bool,
|
|
121
|
+
size: Union[str, int],
|
|
122
|
+
price: Optional[Union[str, int]] = None,
|
|
123
|
+
reduce_only: bool = False,
|
|
124
|
+
tif: Tif = "Gtc",
|
|
125
|
+
cloid: Optional[Union[str, int, Cloid]] = None,
|
|
126
|
+
is_market: bool = False,
|
|
127
|
+
builder_address: Optional[str] = None,
|
|
128
|
+
builder_fee: Optional[int] = None,
|
|
129
|
+
) -> Dict[str, Any]:
|
|
130
|
+
"""Place a single limit or market order. See :meth:`bulk_orders`."""
|
|
131
|
+
request: OrderRequest = {
|
|
132
|
+
"asset": asset,
|
|
133
|
+
"is_buy": is_buy,
|
|
134
|
+
"size": str(size),
|
|
135
|
+
"reduce_only": reduce_only,
|
|
136
|
+
"is_market": is_market,
|
|
137
|
+
}
|
|
138
|
+
if not is_market:
|
|
139
|
+
if price is None:
|
|
140
|
+
raise ValueError("price is required for limit orders")
|
|
141
|
+
request["price"] = str(price)
|
|
142
|
+
request["tif"] = tif
|
|
143
|
+
if cloid is not None:
|
|
144
|
+
request["cloid"] = cloid_str(cloid)
|
|
145
|
+
if builder_address is not None:
|
|
146
|
+
request["builder_address"] = builder_address
|
|
147
|
+
if builder_fee is not None:
|
|
148
|
+
request["builder_fee"] = builder_fee
|
|
149
|
+
return self.bulk_orders([request])
|
|
150
|
+
|
|
151
|
+
def market_order(
|
|
152
|
+
self,
|
|
153
|
+
asset: int,
|
|
154
|
+
is_buy: bool,
|
|
155
|
+
size: Union[str, int],
|
|
156
|
+
reduce_only: bool = False,
|
|
157
|
+
cloid: Optional[Union[str, int, Cloid]] = None,
|
|
158
|
+
) -> Dict[str, Any]:
|
|
159
|
+
"""Place a market order (fills at best price; no ``price``)."""
|
|
160
|
+
return self.order(asset, is_buy, size, reduce_only=reduce_only, cloid=cloid, is_market=True)
|
|
161
|
+
|
|
162
|
+
def bulk_orders(self, orders: List[OrderRequest]) -> Dict[str, Any]:
|
|
163
|
+
"""Place up to 10 orders in one signed request.
|
|
164
|
+
|
|
165
|
+
In a batch, only ``orders[0]``'s builder fields apply to the whole batch.
|
|
166
|
+
"""
|
|
167
|
+
if not 1 <= len(orders) <= constants.MAX_ORDERS_PER_REQUEST:
|
|
168
|
+
raise ValueError(f"orders must contain 1..{constants.MAX_ORDERS_PER_REQUEST} items")
|
|
169
|
+
action = {
|
|
170
|
+
"type": "order",
|
|
171
|
+
"orders": [_order_to_wire(o) for o in orders],
|
|
172
|
+
"grouping": "na",
|
|
173
|
+
}
|
|
174
|
+
return self._post_dict(action)
|
|
175
|
+
|
|
176
|
+
def cancel(self, asset: int, oid: int) -> Dict[str, Any]:
|
|
177
|
+
"""Cancel one resting order by exchange order id."""
|
|
178
|
+
return self.bulk_cancel([{"asset": asset, "oid": oid}])
|
|
179
|
+
|
|
180
|
+
def bulk_cancel(self, cancels: List[CancelRequest]) -> Dict[str, Any]:
|
|
181
|
+
"""Cancel up to 10 orders by order id in one request."""
|
|
182
|
+
if not 1 <= len(cancels) <= constants.MAX_CANCELS_PER_REQUEST:
|
|
183
|
+
raise ValueError(f"cancels must contain 1..{constants.MAX_CANCELS_PER_REQUEST} items")
|
|
184
|
+
action = {"type": "cancel", "cancels": [{"a": c["asset"], "o": c["oid"]} for c in cancels]}
|
|
185
|
+
return self._post_dict(action)
|
|
186
|
+
|
|
187
|
+
def cancel_by_cloid(self, asset: int, cloid: Union[str, int, Cloid]) -> Dict[str, Any]:
|
|
188
|
+
"""Cancel one resting order by client order id."""
|
|
189
|
+
return self.bulk_cancel_by_cloid([{"asset": asset, "cloid": cloid_str(cloid)}])
|
|
190
|
+
|
|
191
|
+
def bulk_cancel_by_cloid(self, cancels: List[CancelByCloidRequest]) -> Dict[str, Any]:
|
|
192
|
+
"""Cancel up to 10 orders by client order id in one request."""
|
|
193
|
+
if not 1 <= len(cancels) <= constants.MAX_CANCELS_PER_REQUEST:
|
|
194
|
+
raise ValueError(f"cancels must contain 1..{constants.MAX_CANCELS_PER_REQUEST} items")
|
|
195
|
+
action = {
|
|
196
|
+
"type": "cancelByCloid",
|
|
197
|
+
"cancels": [{"a": c["asset"], "cloid": cloid_str(c["cloid"])} for c in cancels],
|
|
198
|
+
}
|
|
199
|
+
return self._post_dict(action)
|
|
200
|
+
|
|
201
|
+
def cancel_all(self, asset: int) -> Dict[str, Any]:
|
|
202
|
+
"""Cancel every open limit and conditional order for one contract."""
|
|
203
|
+
return self._post_dict({"type": "cancelAll", "a": asset})
|
|
204
|
+
|
|
205
|
+
def modify(
|
|
206
|
+
self,
|
|
207
|
+
asset: int,
|
|
208
|
+
oid: Optional[int] = None,
|
|
209
|
+
cloid: Optional[Union[str, int, Cloid]] = None,
|
|
210
|
+
price: Optional[Union[str, int]] = None,
|
|
211
|
+
size: Optional[Union[str, int]] = None,
|
|
212
|
+
tif: Optional[Tif] = None,
|
|
213
|
+
new_cloid: Optional[Union[str, int, Cloid]] = None,
|
|
214
|
+
) -> Dict[str, Any]:
|
|
215
|
+
"""Modify a resting order located by ``oid`` (preferred) or ``cloid``.
|
|
216
|
+
|
|
217
|
+
Only price/size/TIF/cloid may change; side, asset, and reduce-only cannot.
|
|
218
|
+
Provide only the fields you want to change.
|
|
219
|
+
"""
|
|
220
|
+
if oid is None and cloid is None:
|
|
221
|
+
raise ValueError("provide oid or cloid to locate the order")
|
|
222
|
+
action: Dict[str, Any] = {"type": "modify", "a": asset}
|
|
223
|
+
if oid is not None:
|
|
224
|
+
action["oid"] = oid
|
|
225
|
+
elif cloid is not None:
|
|
226
|
+
action["cloid"] = as_cloid_str(cloid)
|
|
227
|
+
if price is not None:
|
|
228
|
+
action["p"] = str(price)
|
|
229
|
+
if size is not None:
|
|
230
|
+
action["s"] = str(size)
|
|
231
|
+
if tif is not None:
|
|
232
|
+
action["tif"] = tif
|
|
233
|
+
if new_cloid is not None:
|
|
234
|
+
action["c"] = as_cloid_str(new_cloid)
|
|
235
|
+
return self._post_dict(action)
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------ #
|
|
238
|
+
# Margin & leverage (full-name fields)
|
|
239
|
+
# ------------------------------------------------------------------ #
|
|
240
|
+
def update_leverage(self, asset: int, leverage: int) -> Dict[str, Any]:
|
|
241
|
+
"""Set the leverage multiplier for a contract."""
|
|
242
|
+
return self._post_dict({"type": "updateLeverage", "a": asset, "leverage": leverage})
|
|
243
|
+
|
|
244
|
+
def update_margin_mode(
|
|
245
|
+
self,
|
|
246
|
+
asset: int,
|
|
247
|
+
is_cross: bool,
|
|
248
|
+
is_hedge: Optional[bool] = None,
|
|
249
|
+
) -> Dict[str, Any]:
|
|
250
|
+
"""Switch a contract between cross (``True``) and isolated (``False``).
|
|
251
|
+
|
|
252
|
+
Requires no open position or orders on the contract. Omit ``is_hedge`` to
|
|
253
|
+
keep the current position mode; isolated requires HEDGE.
|
|
254
|
+
"""
|
|
255
|
+
action: Dict[str, Any] = {"type": "updateMarginMode", "asset": asset, "isCross": is_cross}
|
|
256
|
+
if is_hedge is not None:
|
|
257
|
+
action["isHedge"] = is_hedge
|
|
258
|
+
return self._post_dict(action)
|
|
259
|
+
|
|
260
|
+
def update_isolated_margin(
|
|
261
|
+
self,
|
|
262
|
+
asset: int,
|
|
263
|
+
ntli: int,
|
|
264
|
+
is_buy: Optional[bool] = None,
|
|
265
|
+
) -> Dict[str, Any]:
|
|
266
|
+
"""Add (``ntli > 0``) or remove (``ntli < 0``) isolated margin.
|
|
267
|
+
|
|
268
|
+
``is_buy`` selects the side in HEDGE mode; omit it in ONE_WAY.
|
|
269
|
+
"""
|
|
270
|
+
action: Dict[str, Any] = {"type": "updateIsolatedMargin", "asset": asset, "ntli": ntli}
|
|
271
|
+
if is_buy is not None:
|
|
272
|
+
action["isBuy"] = is_buy
|
|
273
|
+
return self._post_dict(action)
|
|
274
|
+
|
|
275
|
+
def update_fee_setting(
|
|
276
|
+
self,
|
|
277
|
+
market_deployer_id: int,
|
|
278
|
+
taker_bps: Optional[int] = None,
|
|
279
|
+
maker_bps: Optional[int] = None,
|
|
280
|
+
) -> Dict[str, Any]:
|
|
281
|
+
"""Set per-user taker/maker fee overrides (bps). Omit a leg to clear it."""
|
|
282
|
+
action: Dict[str, Any] = {"type": "updateFeeSetting", "marketDeployerId": market_deployer_id}
|
|
283
|
+
if taker_bps is not None:
|
|
284
|
+
action["takerBps"] = taker_bps
|
|
285
|
+
if maker_bps is not None:
|
|
286
|
+
action["makerBps"] = maker_bps
|
|
287
|
+
return self._post_dict(action)
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------ #
|
|
290
|
+
# Conditional orders (TP/SL)
|
|
291
|
+
# ------------------------------------------------------------------ #
|
|
292
|
+
def tp_sl(
|
|
293
|
+
self,
|
|
294
|
+
asset: int,
|
|
295
|
+
tp_price: Union[str, int] = "0",
|
|
296
|
+
sl_price: Union[str, int] = "0",
|
|
297
|
+
position_side: int = 0,
|
|
298
|
+
is_position_tpsl: bool = True,
|
|
299
|
+
order_side: Optional[str] = None,
|
|
300
|
+
reduce_only: bool = False,
|
|
301
|
+
tp_limit_price: Union[str, int] = "0",
|
|
302
|
+
sl_limit_price: Union[str, int] = "0",
|
|
303
|
+
tp_size: Union[str, int] = "0",
|
|
304
|
+
sl_size: Union[str, int] = "0",
|
|
305
|
+
tp_trigger_type: int = 0,
|
|
306
|
+
sl_trigger_type: int = 0,
|
|
307
|
+
) -> Dict[str, Any]:
|
|
308
|
+
"""Attach take-profit and/or stop-loss triggers to a position.
|
|
309
|
+
|
|
310
|
+
At least one of ``tp_price`` / ``sl_price`` must be ``> 0``. Limit price
|
|
311
|
+
``"0"`` fires a market IOC on trigger; ``> 0`` a GTC limit. Size ``"0"``
|
|
312
|
+
closes the whole position. Trigger type: 0=mark, 1=index, 2=last.
|
|
313
|
+
"""
|
|
314
|
+
action: Dict[str, Any] = {
|
|
315
|
+
"type": "tpSl",
|
|
316
|
+
"a": asset,
|
|
317
|
+
"positionSide": position_side,
|
|
318
|
+
"isPositionTpsl": is_position_tpsl,
|
|
319
|
+
"reduceOnly": reduce_only,
|
|
320
|
+
"tpPrice": str(tp_price),
|
|
321
|
+
"slPrice": str(sl_price),
|
|
322
|
+
"tpLimitPrice": str(tp_limit_price),
|
|
323
|
+
"slLimitPrice": str(sl_limit_price),
|
|
324
|
+
"tpSize": str(tp_size),
|
|
325
|
+
"slSize": str(sl_size),
|
|
326
|
+
"tpTriggerType": tp_trigger_type,
|
|
327
|
+
"slTriggerType": sl_trigger_type,
|
|
328
|
+
}
|
|
329
|
+
if order_side is not None:
|
|
330
|
+
action["orderSide"] = order_side
|
|
331
|
+
return self._post_dict(action)
|
|
332
|
+
|
|
333
|
+
def cancel_tp_sl(self, asset: int, position_side: int = 0) -> Dict[str, Any]:
|
|
334
|
+
"""Cancel all TP/SL trigger orders for a position (idempotent)."""
|
|
335
|
+
return self._post_dict({"type": "cancelTpSl", "a": asset, "positionSide": position_side})
|
|
336
|
+
|
|
337
|
+
def cancel_conditional(self, oid: int) -> Dict[str, Any]:
|
|
338
|
+
"""Cancel a single conditional order by id."""
|
|
339
|
+
return self._post_dict({"type": "cancelConditional", "oid": oid})
|
|
340
|
+
|
|
341
|
+
# ------------------------------------------------------------------ #
|
|
342
|
+
# Collateral (typed path)
|
|
343
|
+
# ------------------------------------------------------------------ #
|
|
344
|
+
def lock_collateral(self, market_deployer_id: int, coin_id: int, amount: Union[str, int]) -> Dict[str, Any]:
|
|
345
|
+
"""Lock collateral into a market deployer's margin pool (typed path)."""
|
|
346
|
+
return self._post_dict(
|
|
347
|
+
{
|
|
348
|
+
"type": "lockCollateral",
|
|
349
|
+
"marketDeployerId": market_deployer_id,
|
|
350
|
+
"coinId": coin_id,
|
|
351
|
+
"amount": str(amount),
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def unlock_collateral(self, market_deployer_id: int, coin_id: int, amount: Union[str, int]) -> Dict[str, Any]:
|
|
356
|
+
"""Unlock collateral from a market deployer (typed path)."""
|
|
357
|
+
return self._post_dict(
|
|
358
|
+
{
|
|
359
|
+
"type": "unlockCollateral",
|
|
360
|
+
"marketDeployerId": market_deployer_id,
|
|
361
|
+
"coinId": coin_id,
|
|
362
|
+
"amount": str(amount),
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def transfer_between_deployers(
|
|
367
|
+
self,
|
|
368
|
+
from_market_deployer_id: int,
|
|
369
|
+
to_market_deployer_id: int,
|
|
370
|
+
coin_id: int,
|
|
371
|
+
amount: Union[str, int],
|
|
372
|
+
) -> Dict[str, Any]:
|
|
373
|
+
"""Move collateral between two market deployers (typed path)."""
|
|
374
|
+
return self._post_dict(
|
|
375
|
+
{
|
|
376
|
+
"type": "transferBetweenDeployers",
|
|
377
|
+
"fromMarketDeployerId": from_market_deployer_id,
|
|
378
|
+
"toMarketDeployerId": to_market_deployer_id,
|
|
379
|
+
"coinId": coin_id,
|
|
380
|
+
"amount": str(amount),
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# ------------------------------------------------------------------ #
|
|
385
|
+
# Internals
|
|
386
|
+
# ------------------------------------------------------------------ #
|
|
387
|
+
def _post_action(self, action: Dict[str, Any], extra: Optional[Dict[str, Any]] = None) -> Json:
|
|
388
|
+
nonce = self.nonce_manager.next()
|
|
389
|
+
signature: Signature = sign_action(self.wallet, action, nonce)
|
|
390
|
+
envelope: Dict[str, Any] = {"action": action, "signature": signature, "nonce": nonce}
|
|
391
|
+
if extra:
|
|
392
|
+
envelope.update(extra)
|
|
393
|
+
return self.post("/exchange", envelope)
|
|
394
|
+
|
|
395
|
+
def _post_dict(self, action: Dict[str, Any], extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
396
|
+
return self._as_dict(self._post_action(action, extra))
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _as_dict(result: Json) -> Dict[str, Any]:
|
|
400
|
+
assert isinstance(result, dict)
|
|
401
|
+
return result
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _order_to_wire(order: OrderRequest) -> Dict[str, Any]:
|
|
405
|
+
"""Convert a Pythonic :class:`OrderRequest` to the compact wire order object."""
|
|
406
|
+
wire: Dict[str, Any] = {
|
|
407
|
+
"a": order["asset"],
|
|
408
|
+
"b": order["is_buy"],
|
|
409
|
+
"s": str(order["size"]),
|
|
410
|
+
"r": bool(order.get("reduce_only", False)),
|
|
411
|
+
}
|
|
412
|
+
if order.get("is_market"):
|
|
413
|
+
wire["t"] = {"market": {}}
|
|
414
|
+
else:
|
|
415
|
+
if "price" not in order:
|
|
416
|
+
raise ValueError("limit orders require a price")
|
|
417
|
+
wire["p"] = str(order["price"])
|
|
418
|
+
wire["t"] = {"limit": {"tif": order.get("tif", constants.TIF_GTC)}}
|
|
419
|
+
cloid = as_cloid_str(order.get("cloid"))
|
|
420
|
+
if cloid is not None:
|
|
421
|
+
wire["c"] = cloid
|
|
422
|
+
if order.get("builder_address") is not None:
|
|
423
|
+
wire["builderAddress"] = order["builder_address"]
|
|
424
|
+
if order.get("builder_fee") is not None:
|
|
425
|
+
wire["builderFee"] = order["builder_fee"]
|
|
426
|
+
return wire
|
upside/info.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Read-only ``POST /info`` queries and the WebSocket subscription facade.
|
|
2
|
+
|
|
3
|
+
Every method wraps a single ``/info`` call and returns the raw parsed JSON —
|
|
4
|
+
see the per-query field references at https://docs.upsidemax.xyz/info/overview.
|
|
5
|
+
Numbers come back as **raw integer strings**; scale them with the contract's
|
|
6
|
+
``priceScale`` / ``qtyScale`` from :meth:`configs`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Optional, Union
|
|
10
|
+
|
|
11
|
+
from .api import API
|
|
12
|
+
from .utils.types import Json, Subscription
|
|
13
|
+
from .websocket_manager import WebsocketManager, WsCallback
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Info(API):
|
|
17
|
+
"""Client for market, account, and order reads, plus realtime streams."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: Optional[str] = None,
|
|
22
|
+
timeout: Optional[float] = None,
|
|
23
|
+
skip_ws: bool = False,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(base_url, timeout)
|
|
26
|
+
self.ws_manager: Optional[WebsocketManager] = None
|
|
27
|
+
if not skip_ws:
|
|
28
|
+
self.ws_manager = WebsocketManager(self.base_url)
|
|
29
|
+
self.ws_manager.start()
|
|
30
|
+
|
|
31
|
+
# -- market data ------------------------------------------------------
|
|
32
|
+
def configs(self, market_deployer_id: int = 0) -> Dict[str, Any]:
|
|
33
|
+
"""All coin and contract configuration (ids, scales, tick/step, tiers).
|
|
34
|
+
|
|
35
|
+
Cache this — it only changes when contracts are listed. ``priceScale`` /
|
|
36
|
+
``qtyScale`` / ``tickSize`` / ``stepSize`` here are the source of truth
|
|
37
|
+
for scaling every other raw value. Pass ``0`` for all deployers.
|
|
38
|
+
"""
|
|
39
|
+
return self._info({"type": "configs", "marketDeployerId": market_deployer_id})
|
|
40
|
+
|
|
41
|
+
def l2_book(self, asset: Union[int, str]) -> Dict[str, Any]:
|
|
42
|
+
"""Full L2 order-book snapshot. Raises ``ServerError`` (503 ``NOT_READY``)
|
|
43
|
+
before any book exists — retry with backoff."""
|
|
44
|
+
return self._info({"type": "l2Book", "asset": str(asset)})
|
|
45
|
+
|
|
46
|
+
def market_state(self, asset: Union[int, str]) -> Dict[str, Any]:
|
|
47
|
+
"""Mark / oracle / last price, ``priceReady`` flag, and funding index."""
|
|
48
|
+
return self._info({"type": "marketState", "asset": str(asset)})
|
|
49
|
+
|
|
50
|
+
def candle_snapshot(
|
|
51
|
+
self,
|
|
52
|
+
asset: Union[int, str],
|
|
53
|
+
interval: str,
|
|
54
|
+
start_time: Optional[int] = None,
|
|
55
|
+
end_time: Optional[int] = None,
|
|
56
|
+
) -> Dict[str, Any]:
|
|
57
|
+
"""Historical OHLCV candles (ascending by ``t``); the last bar may be open."""
|
|
58
|
+
query: Dict[str, Any] = {"type": "candleSnapshot", "asset": str(asset), "interval": interval}
|
|
59
|
+
if start_time is not None:
|
|
60
|
+
query["startTime"] = start_time
|
|
61
|
+
if end_time is not None:
|
|
62
|
+
query["endTime"] = end_time
|
|
63
|
+
return self._info(query)
|
|
64
|
+
|
|
65
|
+
def share_group_state(self, group_id: int = 0) -> Dict[str, Any]:
|
|
66
|
+
"""Portfolio share-group definitions (membership, settle coin, status)."""
|
|
67
|
+
return self._info({"type": "shareGroupState", "groupId": group_id})
|
|
68
|
+
|
|
69
|
+
# -- account ----------------------------------------------------------
|
|
70
|
+
def user_account(self, account_id: Union[int, str], market_deployer_id: int) -> Dict[str, Any]:
|
|
71
|
+
"""Equity, collateral, margin availability, and positions. Pass
|
|
72
|
+
``market_deployer_id=0`` for an account-wide overview."""
|
|
73
|
+
return self._info({"type": "userAccount", "accountId": str(account_id), "marketDeployerId": market_deployer_id})
|
|
74
|
+
|
|
75
|
+
def user_market_deployers(self, account_id: Union[int, str]) -> Dict[str, Any]:
|
|
76
|
+
"""Market deployer ids the account is enrolled in."""
|
|
77
|
+
return self._info({"type": "userMarketDeployers", "accountId": str(account_id)})
|
|
78
|
+
|
|
79
|
+
def user_agents(self, account_id: Union[int, str]) -> Dict[str, Any]:
|
|
80
|
+
"""Authorized agent (API-wallet) slots for a master account."""
|
|
81
|
+
return self._info({"type": "userAgents", "accountId": str(account_id)})
|
|
82
|
+
|
|
83
|
+
# -- orders -----------------------------------------------------------
|
|
84
|
+
def user_orders(
|
|
85
|
+
self,
|
|
86
|
+
account_id: Union[int, str],
|
|
87
|
+
market_deployer_id: int,
|
|
88
|
+
contract_id: int = 0,
|
|
89
|
+
) -> Dict[str, Any]:
|
|
90
|
+
"""Active open orders; ``contract_id=0`` returns all contracts."""
|
|
91
|
+
return self._info(
|
|
92
|
+
{
|
|
93
|
+
"type": "userOrders",
|
|
94
|
+
"accountId": str(account_id),
|
|
95
|
+
"marketDeployerId": market_deployer_id,
|
|
96
|
+
"contractId": contract_id,
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def orders_by_ids(self, market_deployer_id: int, order_ids: List[Union[int, str]]) -> Dict[str, Any]:
|
|
101
|
+
"""Look up orders by exchange order id (missing ids are omitted)."""
|
|
102
|
+
return self._info(
|
|
103
|
+
{
|
|
104
|
+
"type": "ordersByIds",
|
|
105
|
+
"marketDeployerId": market_deployer_id,
|
|
106
|
+
"orderIds": [str(o) for o in order_ids],
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def orders_by_cloids(
|
|
111
|
+
self,
|
|
112
|
+
account_id: Union[int, str],
|
|
113
|
+
market_deployer_id: int,
|
|
114
|
+
cloids: List[Union[int, str]],
|
|
115
|
+
) -> Dict[str, Any]:
|
|
116
|
+
"""Look up orders by client order id (missing cloids are omitted)."""
|
|
117
|
+
return self._info(
|
|
118
|
+
{
|
|
119
|
+
"type": "ordersByCloids",
|
|
120
|
+
"accountId": str(account_id),
|
|
121
|
+
"marketDeployerId": market_deployer_id,
|
|
122
|
+
"cloids": [str(c) for c in cloids],
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# -- websocket facade -------------------------------------------------
|
|
127
|
+
def subscribe(self, subscription: Subscription, callback: WsCallback) -> int:
|
|
128
|
+
"""Subscribe to a realtime channel; ``callback`` receives each push dict.
|
|
129
|
+
|
|
130
|
+
Example: ``info.subscribe({"type": "l2Book", "asset": "1"}, print)``.
|
|
131
|
+
Returns a subscription id for :meth:`unsubscribe`.
|
|
132
|
+
"""
|
|
133
|
+
return self._require_ws().subscribe(subscription, callback)
|
|
134
|
+
|
|
135
|
+
def unsubscribe(self, subscription: Subscription, subscription_id: int) -> bool:
|
|
136
|
+
"""Remove a previously registered subscription callback."""
|
|
137
|
+
return self._require_ws().unsubscribe(subscription, subscription_id)
|
|
138
|
+
|
|
139
|
+
def ws_authenticate(self, account_id: int) -> None:
|
|
140
|
+
"""Send the optional WebSocket ``Auth`` frame for the connection."""
|
|
141
|
+
self._require_ws().authenticate(account_id)
|
|
142
|
+
|
|
143
|
+
# -- internals --------------------------------------------------------
|
|
144
|
+
def _info(self, query: Dict[str, Any]) -> Dict[str, Any]:
|
|
145
|
+
result: Json = self.post("/info", query)
|
|
146
|
+
assert isinstance(result, dict) # /info queries here return objects
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
def _require_ws(self) -> WebsocketManager:
|
|
150
|
+
if self.ws_manager is None:
|
|
151
|
+
raise RuntimeError("WebSocket disabled — construct Info(skip_ws=False)")
|
|
152
|
+
return self.ws_manager
|
|
153
|
+
|
|
154
|
+
def close(self) -> None:
|
|
155
|
+
if self.ws_manager is not None:
|
|
156
|
+
self.ws_manager.stop()
|
|
157
|
+
super().close()
|
upside/py.typed
ADDED
|
File without changes
|