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/utils/__init__.py
ADDED
|
@@ -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."""
|
upside/utils/signing.py
ADDED
|
@@ -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))
|