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.
@@ -0,0 +1 @@
1
+ """Utility modules: constants, errors, shared types, and EIP-712 signing."""
@@ -0,0 +1,49 @@
1
+ """Environment URLs and protocol constants for the Upside API.
2
+
3
+ Contract IDs, coin IDs, price/quantity scales, and tick/step sizes are **not**
4
+ constants — they are server-assigned and differ per environment. Always read
5
+ them from ``Info.configs()`` rather than hardcoding.
6
+ """
7
+
8
+ # REST + WebSocket base URLs. The base URL is the scheme + host only; the SDK
9
+ # appends ``/info``, ``/exchange`` for REST and derives the ``/ws`` path.
10
+ QA_API_URL = "https://dev.upsidemax.xyz"
11
+ UAT_API_URL = "http://chaindex-chainnode-nlb-fdaf7d3d8c724a80.elb.ap-northeast-1.amazonaws.com:9093"
12
+ # Production URL is not yet published.
13
+ MAINNET_API_URL = QA_API_URL
14
+
15
+ # EIP-712 signing domain (see https://docs.upsidemax.xyz/guide/authentication).
16
+ CHAIN_ID = 9767
17
+ DOMAIN_NAME = "Exchange"
18
+ DOMAIN_VERSION = "1"
19
+ VERIFYING_CONTRACT = "0x0000000000000000000000000000000000000000"
20
+ # Source string folded into the Agent-path struct.
21
+ AGENT_SOURCE = "b"
22
+
23
+ # Batch limits enforced by the gateway.
24
+ MAX_ORDERS_PER_REQUEST = 10
25
+ MAX_CANCELS_PER_REQUEST = 10
26
+
27
+ # Candle intervals accepted by ``candleSnapshot`` and the ``candle`` WS channel.
28
+ INTERVALS = (
29
+ "1m",
30
+ "3m",
31
+ "5m",
32
+ "15m",
33
+ "30m",
34
+ "1h",
35
+ "2h",
36
+ "4h",
37
+ "8h",
38
+ "12h",
39
+ "1d",
40
+ "3d",
41
+ "1w",
42
+ "1M",
43
+ )
44
+
45
+ # Time-in-force values accepted by the ``order`` action.
46
+ TIF_GTC = "Gtc"
47
+ TIF_IOC = "Ioc"
48
+ TIF_ALO = "Alo"
49
+ TIF_FOK = "Fok"
upside/utils/error.py ADDED
@@ -0,0 +1,51 @@
1
+ """Exception hierarchy for the Upside SDK.
2
+
3
+ Upside reports failures at two levels:
4
+
5
+ * **HTTP / gateway level** — a non-2xx response (or a body with
6
+ ``"status": "error"``) carrying a machine ``code`` and ``message``. These
7
+ raise :class:`ClientError` (4xx) or :class:`ServerError` (5xx).
8
+ * **Business level** — an HTTP 200 with ``"status": "ok"`` that still contains
9
+ a per-item ``error`` string inside ``statuses[]`` or a non-zero ``errorCode``
10
+ inside ``response.data``. These are **not** raised — inspect the returned
11
+ dict. See https://docs.upsidemax.xyz/guide/error-codes.
12
+ """
13
+
14
+ from typing import Optional
15
+
16
+
17
+ class UpsideError(Exception):
18
+ """Base class for every error raised by the SDK."""
19
+
20
+
21
+ class APIError(UpsideError):
22
+ """An HTTP/gateway-level rejection from ``/info`` or ``/exchange``.
23
+
24
+ Attributes mirror the error envelope
25
+ ``{"status": "error", "requestId", "code", "message"}``.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ status_code: int,
31
+ code: Optional[str] = None,
32
+ message: Optional[str] = None,
33
+ request_id: Optional[str] = None,
34
+ ) -> None:
35
+ self.status_code = status_code
36
+ self.code = code
37
+ self.message = message
38
+ self.request_id = request_id
39
+ super().__init__(f"HTTP {status_code} {code or ''}: {message or ''}".rstrip())
40
+
41
+
42
+ class ClientError(APIError):
43
+ """A 4xx rejection (bad request, signature invalid, nonce reused, ...)."""
44
+
45
+
46
+ class ServerError(APIError):
47
+ """A 5xx error, or a gateway timeout (504) / downstream failure (503)."""
48
+
49
+
50
+ class WebsocketError(UpsideError):
51
+ """Raised for WebSocket subscription/protocol problems."""
@@ -0,0 +1,172 @@
1
+ """EIP-712 request signing for ``POST /exchange``.
2
+
3
+ Every write is authorized by an ECDSA signature (secp256k1) over an EIP-712
4
+ digest; the server recovers the signer's address — there are no API keys. Two
5
+ signing paths, selected by ``action["type"]``:
6
+
7
+ * **Typed path** — the six funds/permission actions, each with a field-level
8
+ struct so a wallet can render human-readable values.
9
+ * **Agent path** — everything else. The whole canonical-JSON action is folded
10
+ into a single ``actionHash`` carried by ``Agent(string source, bytes32 actionHash)``.
11
+
12
+ This is a direct port of the reference implementation published at
13
+ https://docs.upsidemax.xyz/guide/authentication — the digest formula must match
14
+ the server byte-for-byte or recovery fails with ``SIGNATURE_INVALID``.
15
+ """
16
+
17
+ import json
18
+ import threading
19
+ import time
20
+ from typing import Any, Dict, Union, cast
21
+
22
+ from eth_account import Account
23
+ from eth_account.signers.local import LocalAccount
24
+ from eth_utils import keccak
25
+
26
+ from . import constants
27
+
28
+ Wallet = LocalAccount
29
+ Signature = Dict[str, Any]
30
+
31
+
32
+ def _u(value: Union[int, str]) -> bytes:
33
+ """Encode an unsigned integer as a 32-byte big-endian word."""
34
+ return int(value).to_bytes(32, "big")
35
+
36
+
37
+ def _addr(value: str) -> bytes:
38
+ """Encode an address as 20 bytes left-padded to 32."""
39
+ hex_body = value[2:] if value[:2].lower() == "0x" else value
40
+ return b"\x00" * 12 + bytes.fromhex(hex_body)
41
+
42
+
43
+ def _string(value: Any) -> bytes:
44
+ """Encode a string field as ``keccak256(utf8Bytes)``."""
45
+ return keccak(str(value).encode())
46
+
47
+
48
+ # EIP-712 domain separator: Exchange / v1 / chainId 9767 / verifyingContract 0x0.
49
+ _DOMAIN_SEPARATOR = keccak(
50
+ keccak(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
51
+ + keccak(constants.DOMAIN_NAME.encode())
52
+ + keccak(constants.DOMAIN_VERSION.encode())
53
+ + _u(constants.CHAIN_ID)
54
+ + b"\x00" * 32
55
+ )
56
+
57
+ # Funds / permission actions sign a field-level typed struct. Field names match
58
+ # the action's JSON keys, so encodings are derived straight from the type string.
59
+ _TYPED: Dict[str, str] = {
60
+ "registerAccount": "RegisterAccount(address address,uint64 nonce)",
61
+ "approveAgent": "ApproveAgent(address agentAddress,string agentName,uint64 validUntil,uint64 nonce)",
62
+ "revokeAgent": "RevokeAgent(address agentAddress,uint64 nonce)",
63
+ "lockCollateral": "LockCollateral(uint32 marketDeployerId,uint32 coinId,string amount,uint64 nonce)",
64
+ "unlockCollateral": "UnlockCollateral(uint32 marketDeployerId,uint32 coinId,string amount,uint64 nonce)",
65
+ "transferBetweenDeployers": (
66
+ "TransferBetweenDeployers(uint32 fromMarketDeployerId,uint32 toMarketDeployerId,"
67
+ "uint32 coinId,string amount,uint64 nonce)"
68
+ ),
69
+ }
70
+
71
+ # Optional typed fields MUST still appear in the wire JSON — the server reads
72
+ # them by name to recompute the digest. Inject defaults before signing so the
73
+ # signed struct and the sent JSON match, otherwise recovery yields a different
74
+ # address (401 SIGNATURE_INVALID).
75
+ _TYPED_DEFAULTS: Dict[str, Dict[str, Any]] = {
76
+ "approveAgent": {"agentName": "", "validUntil": 0},
77
+ }
78
+
79
+
80
+ def _encode_field(sol_type: str, value: Any) -> bytes:
81
+ if sol_type == "address":
82
+ return _addr(value)
83
+ if sol_type == "string":
84
+ return _string(value)
85
+ return _u(value) # uintN
86
+
87
+
88
+ def _typed_struct(type_str: str, action: Dict[str, Any], nonce: int) -> bytes:
89
+ fields = [f.split() for f in type_str[type_str.index("(") + 1 : -1].split(",")]
90
+ encoded = [_encode_field(sol_type, nonce if name == "nonce" else action[name]) for sol_type, name in fields]
91
+ return keccak(keccak(type_str.encode()) + b"".join(encoded))
92
+
93
+
94
+ def action_hash(action: Dict[str, Any], nonce: int) -> bytes:
95
+ """Agent-path ``actionHash`` = keccak(canonicalJson ‖ nonce_be8)."""
96
+ canonical = json.dumps(action, sort_keys=True, separators=(",", ":")).encode()
97
+ return keccak(canonical + int(nonce).to_bytes(8, "big"))
98
+
99
+
100
+ def eip712_digest(action: Dict[str, Any], nonce: int) -> bytes:
101
+ """Compute the 32-byte EIP-712 digest for an action.
102
+
103
+ For typed actions this mutates ``action`` to fill required-but-optional
104
+ fields (e.g. ``agentName``) so the sent JSON matches the signed struct.
105
+ """
106
+ type_str = _TYPED.get(action["type"])
107
+ if type_str is not None:
108
+ for key, default in _TYPED_DEFAULTS.get(action["type"], {}).items():
109
+ action.setdefault(key, default)
110
+ struct = _typed_struct(type_str, action, nonce)
111
+ else:
112
+ struct = keccak(
113
+ keccak(b"Agent(string source,bytes32 actionHash)")
114
+ + keccak(constants.AGENT_SOURCE.encode())
115
+ + action_hash(action, nonce)
116
+ )
117
+ return keccak(b"\x19\x01" + _DOMAIN_SEPARATOR + struct)
118
+
119
+
120
+ def _sign_digest(wallet: Wallet, digest: bytes) -> Any:
121
+ """Sign a 32-byte digest directly (``prehash = false``), across eth-account versions."""
122
+ signer = getattr(Account, "unsafe_sign_hash", None) or Account._sign_hash
123
+ return signer(digest, wallet.key)
124
+
125
+
126
+ def sign_action(wallet: Wallet, action: Dict[str, Any], nonce: int) -> Signature:
127
+ """Return the ``{"r", "s", "v"}`` signature envelope for ``action``.
128
+
129
+ ``r``/``s`` are ``0x``-prefixed lowercase 32-byte hex; ``v`` is 27 or 28.
130
+ """
131
+ signed = _sign_digest(wallet, eip712_digest(action, nonce))
132
+ return {
133
+ "r": "0x" + int(signed.r).to_bytes(32, "big").hex(),
134
+ "s": "0x" + int(signed.s).to_bytes(32, "big").hex(),
135
+ "v": signed.v if signed.v >= 27 else signed.v + 27,
136
+ }
137
+
138
+
139
+ def to_wallet(wallet_or_key: Union[Wallet, str]) -> Wallet:
140
+ """Coerce a private-key hex string into a :class:`LocalAccount`."""
141
+ if isinstance(wallet_or_key, str):
142
+ return cast(Wallet, Account.from_key(wallet_or_key))
143
+ return wallet_or_key
144
+
145
+
146
+ class NonceManager:
147
+ """Thread-safe, strictly increasing millisecond nonce source.
148
+
149
+ Each signing address needs unique, monotonic nonces; the server rejects a
150
+ reused or regressing nonce with ``NONCE_REUSED``. Sub-millisecond bursts are
151
+ handled by incrementing past the last issued value.
152
+ """
153
+
154
+ def __init__(self) -> None:
155
+ self._lock = threading.Lock()
156
+ self._last = 0
157
+
158
+ def next(self) -> int:
159
+ with self._lock:
160
+ self._last = max(self._last + 1, int(time.time() * 1000))
161
+ return self._last
162
+
163
+
164
+ __all__ = [
165
+ "Wallet",
166
+ "Signature",
167
+ "NonceManager",
168
+ "action_hash",
169
+ "eip712_digest",
170
+ "sign_action",
171
+ "to_wallet",
172
+ ]
upside/utils/types.py ADDED
@@ -0,0 +1,133 @@
1
+ """Shared types for the Upside SDK.
2
+
3
+ The SDK keeps two vocabularies:
4
+
5
+ * the **request** side (Pythonic, ``snake_case``) used by public method
6
+ signatures and the :class:`OrderRequest` / :class:`ModifyRequest` TypedDicts, and
7
+ * the **wire** side (compact single-letter keys, string numbers) that goes into
8
+ the signed ``action`` payload.
9
+
10
+ Conversion between the two happens in :mod:`upside.exchange`. Responses are
11
+ returned as raw parsed JSON (``dict`` / ``list``) — inspect them by key.
12
+ """
13
+
14
+ import sys
15
+ from typing import Any, Dict, List, Literal, Optional, Union
16
+
17
+ if sys.version_info >= (3, 11):
18
+ from typing import NotRequired, TypedDict
19
+ else: # ``NotRequired`` was added to ``typing`` in 3.11
20
+ from typing_extensions import NotRequired, TypedDict
21
+
22
+ # Raw JSON returned by ``/info`` and ``/exchange``.
23
+ Json = Union[Dict[str, Any], List[Any]]
24
+
25
+ # Time-in-force policies accepted by the ``order`` action.
26
+ Tif = Literal["Gtc", "Ioc", "Alo", "Fok"]
27
+
28
+ # Trigger price feeds for TP/SL: 0 = mark, 1 = index, 2 = last.
29
+ TriggerType = Literal[0, 1, 2]
30
+
31
+ # Position side for conditional orders / margin ops: 0 = ONE_WAY, 1 = LONG, 2 = SHORT.
32
+ PositionSide = Literal[0, 1, 2]
33
+
34
+
35
+ class OrderRequest(TypedDict):
36
+ """A single order in a (possibly batched) ``order`` action.
37
+
38
+ Prices and sizes are **raw integer strings** — scale them with the
39
+ contract's ``priceScale`` / ``qtyScale`` from ``configs``. ``price`` is
40
+ required for limit orders and omitted for market orders (``is_market``).
41
+ """
42
+
43
+ asset: int
44
+ is_buy: bool
45
+ size: str
46
+ price: NotRequired[str]
47
+ reduce_only: NotRequired[bool]
48
+ tif: NotRequired[Tif]
49
+ is_market: NotRequired[bool]
50
+ cloid: NotRequired[str]
51
+ builder_address: NotRequired[str]
52
+ builder_fee: NotRequired[int]
53
+
54
+
55
+ class CancelRequest(TypedDict):
56
+ """A single cancel-by-order-id entry."""
57
+
58
+ asset: int
59
+ oid: int
60
+
61
+
62
+ class CancelByCloidRequest(TypedDict):
63
+ """A single cancel-by-client-order-id entry."""
64
+
65
+ asset: int
66
+ cloid: str
67
+
68
+
69
+ class Subscription(TypedDict):
70
+ """A WebSocket subscription object (the ``subscription`` field on the wire).
71
+
72
+ ``asset`` is a decimal string contract id; ``user`` is a lowercase wallet
73
+ address. Only the fields relevant to ``type`` are read.
74
+ """
75
+
76
+ type: str
77
+ asset: NotRequired[str]
78
+ interval: NotRequired[str]
79
+ user: NotRequired[str]
80
+
81
+
82
+ class Cloid:
83
+ """A client order id: a positive ``int64`` sent as a decimal string.
84
+
85
+ Upside represents client order ids as ``int64`` decimal strings (unlike
86
+ Hyperliquid's 128-bit hex cloids). Use this wrapper to build one from an
87
+ int, a timestamp, or a validated string.
88
+ """
89
+
90
+ __slots__ = ("_value",)
91
+
92
+ _MAX = 2**63 - 1
93
+
94
+ def __init__(self, raw: str) -> None:
95
+ value = int(raw)
96
+ if value <= 0 or value > self._MAX:
97
+ raise ValueError(f"cloid must be a positive int64 decimal string, got {raw!r}")
98
+ self._value = value
99
+
100
+ @classmethod
101
+ def from_int(cls, value: int) -> "Cloid":
102
+ return cls(str(value))
103
+
104
+ def to_raw(self) -> str:
105
+ """The wire representation: a decimal string."""
106
+ return str(self._value)
107
+
108
+ def to_int(self) -> int:
109
+ return self._value
110
+
111
+ def __str__(self) -> str:
112
+ return self.to_raw()
113
+
114
+ def __repr__(self) -> str:
115
+ return f"Cloid({self.to_raw()!r})"
116
+
117
+ def __eq__(self, other: object) -> bool:
118
+ return isinstance(other, Cloid) and other._value == self._value
119
+
120
+ def __hash__(self) -> int:
121
+ return hash(self._value)
122
+
123
+
124
+ def cloid_str(value: Union[str, int, Cloid]) -> str:
125
+ """Normalize a required cloid-like value to its wire decimal string."""
126
+ if isinstance(value, Cloid):
127
+ return value.to_raw()
128
+ return Cloid(str(value)).to_raw()
129
+
130
+
131
+ def as_cloid_str(value: Optional[Union[str, int, Cloid]]) -> Optional[str]:
132
+ """Normalize an optional cloid-like value to its wire decimal string (or ``None``)."""
133
+ return None if value is None else cloid_str(value)
@@ -0,0 +1,199 @@
1
+ """Threaded WebSocket client for the Upside realtime API.
2
+
3
+ Runs ``websocket-client``'s ``run_forever`` on a background thread with built-in
4
+ ping and auto-reconnect. Subscriptions use the v0.14 protocol
5
+ ``{"method": "subscribe", "subscription": {...}}``; each is keyed by a stable
6
+ *identifier* so incoming pushes can be routed to the right callback(s), and so
7
+ active subscriptions can be replayed after a reconnect.
8
+
9
+ See https://docs.upsidemax.xyz/websocket/overview.
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ import threading
15
+ from collections import defaultdict
16
+ from typing import Any, Callable, Dict, List, NamedTuple, Optional
17
+
18
+ import websocket
19
+
20
+ from .utils.error import WebsocketError
21
+ from .utils.types import Json, Subscription
22
+
23
+ WsCallback = Callable[[Json], None]
24
+
25
+ # Control channels that carry no subscription payload.
26
+ _CONTROL_CHANNELS = {"subscriptionResponse", "error", "pong"}
27
+
28
+ # Inbound channel prefix (before the ".<addr>") -> subscription type.
29
+ _USER_CHANNEL_TO_TYPE = {
30
+ "orderUpdates": "orderUpdates",
31
+ "openOrders": "openOrders",
32
+ "fills": "userFills",
33
+ }
34
+
35
+
36
+ def subscription_to_identifier(subscription: Subscription) -> str:
37
+ """Stable key for a subscription, matched against inbound messages."""
38
+ sub_type = subscription["type"]
39
+ if sub_type in ("l2Book", "bbo", "trades"):
40
+ return f'{sub_type}:{subscription["asset"]}'
41
+ if sub_type == "candle":
42
+ return f'candle:{subscription["asset"]},{subscription["interval"]}'
43
+ if sub_type in ("orderUpdates", "openOrders", "userFills"):
44
+ return f'{sub_type}:{str(subscription["user"]).lower()}'
45
+ if sub_type == "config":
46
+ return "config"
47
+ raise WebsocketError(f"unknown subscription type: {sub_type}")
48
+
49
+
50
+ def ws_message_to_identifier(message: Dict[str, Any]) -> Optional[str]:
51
+ """Derive the identifier for an inbound push, or ``None`` for control frames."""
52
+ channel = message.get("channel")
53
+ if channel is None or channel in _CONTROL_CHANNELS:
54
+ return None
55
+
56
+ data: Any = message.get("data")
57
+ if channel in ("l2Book", "bbo"):
58
+ return f'{channel}:{data.get("asset")}'
59
+ if channel == "trades":
60
+ first = data[0] if isinstance(data, list) and data else {}
61
+ return f'trades:{first.get("asset")}'
62
+ if channel == "candle":
63
+ return f'candle:{data.get("s")},{data.get("i")}'
64
+ if channel == "config":
65
+ return "config"
66
+
67
+ # User channels arrive as "<base>.<address>" (e.g. "fills.0xabc").
68
+ base, _, addr = channel.partition(".")
69
+ sub_type = _USER_CHANNEL_TO_TYPE.get(base)
70
+ if sub_type is not None:
71
+ return f"{sub_type}:{addr.lower()}"
72
+ return None
73
+
74
+
75
+ class _ActiveSubscription(NamedTuple):
76
+ callback: WsCallback
77
+ subscription_id: int
78
+ subscription: Subscription
79
+
80
+
81
+ class WebsocketManager(threading.Thread):
82
+ """Background WebSocket connection with subscribe/dispatch/reconnect."""
83
+
84
+ def __init__(self, base_url: str, ping_interval: int = 30, user_agent: str = "upside-python-sdk") -> None:
85
+ super().__init__(daemon=True)
86
+ # http(s)://host -> ws(s)://host/ws
87
+ self.ws_url = "ws" + base_url.rstrip("/")[len("http") :] + "/ws"
88
+ self.ping_interval = ping_interval
89
+ # A User-Agent header is required to pass the CloudFront edge in front of QA.
90
+ self._headers = [f"User-Agent: {user_agent}"]
91
+ self._logger = logging.getLogger("upside.ws")
92
+
93
+ self._lock = threading.Lock()
94
+ self._id_counter = 0
95
+ self.ws_ready = False
96
+ self._auth_account_id: Optional[int] = None
97
+ self._active: Dict[str, List[_ActiveSubscription]] = defaultdict(list)
98
+
99
+ self.ws = websocket.WebSocketApp(
100
+ self.ws_url,
101
+ header=self._headers,
102
+ on_open=self._on_open,
103
+ on_message=self._on_message,
104
+ on_error=self._on_error,
105
+ on_close=self._on_close,
106
+ )
107
+
108
+ # -- thread entrypoint ------------------------------------------------
109
+ def run(self) -> None:
110
+ self.ws.run_forever(ping_interval=self.ping_interval, ping_timeout=self.ping_interval - 5, reconnect=5)
111
+
112
+ def stop(self) -> None:
113
+ try:
114
+ self.ws.close()
115
+ except Exception: # pragma: no cover - best-effort teardown
116
+ pass
117
+
118
+ # -- public API -------------------------------------------------------
119
+ def authenticate(self, account_id: int) -> None:
120
+ """Send an ``Auth`` frame (optional today; recommended for forward compat)."""
121
+ self._auth_account_id = account_id
122
+ if self.ws_ready:
123
+ self._send({"msg": "Auth", "accountId": account_id})
124
+
125
+ def subscribe(self, subscription: Subscription, callback: WsCallback) -> int:
126
+ """Register ``callback`` for ``subscription`` and return a subscription id."""
127
+ identifier = subscription_to_identifier(subscription)
128
+ with self._lock:
129
+ self._id_counter += 1
130
+ sub_id = self._id_counter
131
+ first_for_identifier = not self._active[identifier]
132
+ self._active[identifier].append(_ActiveSubscription(callback, sub_id, subscription))
133
+ # If the socket isn't ready yet, _on_open replays every active
134
+ # subscription; only send now for the first callback of an identifier.
135
+ if self.ws_ready and first_for_identifier:
136
+ self._send({"method": "subscribe", "subscription": dict(subscription)})
137
+ return sub_id
138
+
139
+ def unsubscribe(self, subscription: Subscription, subscription_id: int) -> bool:
140
+ """Remove one callback; send ``unsubscribe`` when the last one is gone."""
141
+ identifier = subscription_to_identifier(subscription)
142
+ with self._lock:
143
+ entries = self._active.get(identifier, [])
144
+ remaining = [e for e in entries if e.subscription_id != subscription_id]
145
+ removed = len(remaining) != len(entries)
146
+ if remaining:
147
+ self._active[identifier] = remaining
148
+ else:
149
+ self._active.pop(identifier, None)
150
+ if removed and self.ws_ready:
151
+ self._send({"method": "unsubscribe", "subscription": dict(subscription)})
152
+ return removed
153
+
154
+ # -- socket callbacks -------------------------------------------------
155
+ def _on_open(self, _ws: Any) -> None:
156
+ self._logger.debug("websocket open: %s", self.ws_url)
157
+ with self._lock:
158
+ self.ws_ready = True
159
+ if self._auth_account_id is not None:
160
+ self._send({"msg": "Auth", "accountId": self._auth_account_id})
161
+ # Replay every active subscription (covers both first connect and reconnect).
162
+ for entries in self._active.values():
163
+ if entries:
164
+ self._send({"method": "subscribe", "subscription": dict(entries[0].subscription)})
165
+
166
+ def _on_message(self, _ws: Any, raw: str) -> None:
167
+ try:
168
+ message = json.loads(raw)
169
+ except json.JSONDecodeError:
170
+ self._logger.warning("dropping non-JSON ws frame: %s", raw[:120])
171
+ return
172
+ if not isinstance(message, dict):
173
+ return
174
+
175
+ if message.get("channel") == "error":
176
+ self._logger.warning("ws subscription error: %s", message.get("data"))
177
+
178
+ identifier = ws_message_to_identifier(message)
179
+ if identifier is None:
180
+ return
181
+ with self._lock:
182
+ callbacks = [e.callback for e in self._active.get(identifier, [])]
183
+ for callback in callbacks:
184
+ try:
185
+ callback(message)
186
+ except Exception: # pragma: no cover - user callback error
187
+ self._logger.exception("error in ws callback for %s", identifier)
188
+
189
+ def _on_error(self, _ws: Any, error: Any) -> None:
190
+ self._logger.warning("websocket error: %s", error)
191
+
192
+ def _on_close(self, _ws: Any, status_code: Any, msg: Any) -> None:
193
+ self._logger.debug("websocket closed: %s %s", status_code, msg)
194
+ with self._lock:
195
+ self.ws_ready = False
196
+
197
+ # -- internals --------------------------------------------------------
198
+ def _send(self, payload: Dict[str, Any]) -> None:
199
+ self.ws.send(json.dumps(payload))