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 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