viper-execution 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.
- viper/__init__.py +48 -0
- viper/exceptions.py +55 -0
- viper/py.typed +0 -0
- viper/ws.py +653 -0
- viper_execution-0.1.0.dist-info/METADATA +110 -0
- viper_execution-0.1.0.dist-info/RECORD +8 -0
- viper_execution-0.1.0.dist-info/WHEEL +4 -0
- viper_execution-0.1.0.dist-info/licenses/LICENSE +21 -0
viper/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Viper Execution Python SDK.
|
|
2
|
+
|
|
3
|
+
Institutional-grade client for the Viper Execution trading API on Hyperliquid.
|
|
4
|
+
|
|
5
|
+
This release ships the resilient WebSocket client (`ViperWSClient`) and the
|
|
6
|
+
resync REST-fetch mapping. The full typed REST client lands in a subsequent
|
|
7
|
+
release.
|
|
8
|
+
|
|
9
|
+
Quickstart:
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from viper import ViperWSClient
|
|
13
|
+
|
|
14
|
+
async def main():
|
|
15
|
+
client = ViperWSClient(
|
|
16
|
+
api_key_id="vk_...",
|
|
17
|
+
api_secret="...",
|
|
18
|
+
handle="your-handle",
|
|
19
|
+
wallet="0x...",
|
|
20
|
+
on_event=lambda f: print(f["channel"], f.get("event")),
|
|
21
|
+
)
|
|
22
|
+
await client.start()
|
|
23
|
+
await client.subscribe("account.state", "0x...")
|
|
24
|
+
await asyncio.sleep(30)
|
|
25
|
+
await client.close()
|
|
26
|
+
|
|
27
|
+
asyncio.run(main())
|
|
28
|
+
|
|
29
|
+
Note: the SDK version is independent of the API version. This is SDK 0.x
|
|
30
|
+
against API v1.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
from .ws import (
|
|
36
|
+
ViperWSClient,
|
|
37
|
+
RESYNC_ENDPOINTS,
|
|
38
|
+
resync_endpoint,
|
|
39
|
+
make_rest_fetcher,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"ViperWSClient",
|
|
44
|
+
"RESYNC_ENDPOINTS",
|
|
45
|
+
"resync_endpoint",
|
|
46
|
+
"make_rest_fetcher",
|
|
47
|
+
"__version__",
|
|
48
|
+
]
|
viper/exceptions.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Viper SDK exception hierarchy.
|
|
2
|
+
|
|
3
|
+
A single base (`ViperError`) so callers can `except ViperError` to catch anything
|
|
4
|
+
the SDK raises, with specific subclasses for the cases worth handling distinctly.
|
|
5
|
+
The WS client surfaces most conditions through callbacks (`on_terminal`,
|
|
6
|
+
`on_command_result`) rather than exceptions; these types are for the REST client
|
|
7
|
+
and for the few WS paths that raise.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ViperError(Exception):
|
|
15
|
+
"""Base class for all SDK errors."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ViperAuthError(ViperError):
|
|
19
|
+
"""Authentication/authorization failure (bad signature, revoked key, 401/403).
|
|
20
|
+
|
|
21
|
+
On the WS channel, credential revocation arrives as a terminal close (4013)
|
|
22
|
+
via `on_terminal`, not as this exception.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ViperConnectionError(ViperError):
|
|
27
|
+
"""Transport-level failure: handshake rejected, socket dropped, reconnect
|
|
28
|
+
attempts exhausted."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ViperRateLimitError(ViperError):
|
|
32
|
+
"""Request rejected by a rate-limit gate (HTTP 429 / WS subscription cap)."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, retry_after: Optional[float] = None):
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.retry_after = retry_after
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ViperAPIError(ViperError):
|
|
40
|
+
"""The API returned an error response. Carries status + server payload."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, status: Optional[int] = None,
|
|
43
|
+
payload: Optional[dict] = None):
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.status = status
|
|
46
|
+
self.payload = payload
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"ViperError",
|
|
51
|
+
"ViperAuthError",
|
|
52
|
+
"ViperConnectionError",
|
|
53
|
+
"ViperRateLimitError",
|
|
54
|
+
"ViperAPIError",
|
|
55
|
+
]
|
viper/py.typed
ADDED
|
File without changes
|
viper/ws.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ws.py — Viper v1 resilient WebSocket client (reference implementation)
|
|
4
|
+
|
|
5
|
+
This is the canonical resilient consumer for Viper's `/v1/ws` bot-dev stream.
|
|
6
|
+
It is built to be read top-to-bottom: it doubles as the SDK's WS half and as
|
|
7
|
+
the backbone of the streaming examples.
|
|
8
|
+
|
|
9
|
+
Design
|
|
10
|
+
------
|
|
11
|
+
The resilience skeleton (two-layer liveness, watchdog, fire-and-forget
|
|
12
|
+
reconnect, exponential backoff, resubscribe-all) uses production-proven
|
|
13
|
+
defaults; the thresholds below are battle-tested values.
|
|
14
|
+
|
|
15
|
+
The protocol layer (HMAC handshake, welcome/_meta consumption, per-scope
|
|
16
|
+
`last_seq` cursors, resync recovery, `data.wallet` routing, `4013` terminal)
|
|
17
|
+
is specific to `/v1/ws` and follows the Streams reference + the resilience
|
|
18
|
+
contract. Hyperliquid itself has no ring-buffer/resync model, so the replay
|
|
19
|
+
layer is Viper-specific.
|
|
20
|
+
|
|
21
|
+
Two-layer liveness (the key pattern)
|
|
22
|
+
------------------------------------------------------------
|
|
23
|
+
1. Transport (universal): the `websockets` library's own ping_interval /
|
|
24
|
+
ping_timeout detects a dead or half-open peer in <=15s on EVERY connection,
|
|
25
|
+
including idle ones. The library auto-answers server pings and does NOT
|
|
26
|
+
surface them to the app message loop.
|
|
27
|
+
2. Data-staleness (cadence-bearing subscriptions only): an app-level timer
|
|
28
|
+
that fires ONLY for subscriptions expected to push continuously (a running
|
|
29
|
+
`execution.state`, an actively-trading `account.state`). For event-only /
|
|
30
|
+
idle subscriptions, silence is normal — a data-silence timer would
|
|
31
|
+
false-positive, so they rely solely on layer 1.
|
|
32
|
+
|
|
33
|
+
Usage
|
|
34
|
+
-----
|
|
35
|
+
client = ViperWSClient(
|
|
36
|
+
api_key_id=..., api_secret=..., handle=...,
|
|
37
|
+
on_event=lambda f: ..., # channel events (route by data.wallet)
|
|
38
|
+
on_meta=lambda f: ..., # _meta upstream transitions (info only)
|
|
39
|
+
on_terminal=lambda code: ..., # 4013 revocation — do not reconnect
|
|
40
|
+
rest_fetch_current_state=async (channel, scope_id) -> None, # resync recovery
|
|
41
|
+
)
|
|
42
|
+
await client.start() # connect + run forever
|
|
43
|
+
await client.subscribe("execution.state", exec_id, cadence_bearing=True)
|
|
44
|
+
await client.subscribe("account.state", wallet, cadence_bearing=True)
|
|
45
|
+
|
|
46
|
+
Deps: pip install websockets httpx
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
import asyncio
|
|
50
|
+
import hashlib
|
|
51
|
+
import hmac
|
|
52
|
+
import json
|
|
53
|
+
import time
|
|
54
|
+
import uuid
|
|
55
|
+
from dataclasses import dataclass, field
|
|
56
|
+
from typing import Awaitable, Callable, Dict, Optional, Tuple
|
|
57
|
+
|
|
58
|
+
import websockets
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------
|
|
62
|
+
# Handshake signing — wire-verified against the live /v1/ws handshake.
|
|
63
|
+
# Do not reconstruct from memory.
|
|
64
|
+
# Re-auth happens ONLY at handshake; frames are not individually signed.
|
|
65
|
+
# --------------------------------------------------------------------------
|
|
66
|
+
def _handshake_headers(api_key_id: str, api_secret: str, ws_path: str,
|
|
67
|
+
handle: Optional[str], wallet: Optional[str]) -> Dict[str, str]:
|
|
68
|
+
"""Fresh signed headers for a `/v1/ws` upgrade. Signs `"{ts}GET{ws_path}"`,
|
|
69
|
+
no body. A new ts is generated per call, so every (re)connect re-signs."""
|
|
70
|
+
ts = str(int(time.time()))
|
|
71
|
+
payload = f"{ts}GET{ws_path}".encode()
|
|
72
|
+
sig = hmac.new(api_secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
73
|
+
h = {
|
|
74
|
+
"X-Viper-Api-Key-Id": api_key_id,
|
|
75
|
+
"X-Viper-Signature": sig,
|
|
76
|
+
"X-Viper-Timestamp": ts,
|
|
77
|
+
}
|
|
78
|
+
if handle:
|
|
79
|
+
h["X-Viper-Handle"] = handle
|
|
80
|
+
if wallet:
|
|
81
|
+
# Disambiguates which wallet on a multi-wallet connection.
|
|
82
|
+
h["Viper-Wallet"] = wallet
|
|
83
|
+
return h
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _handshake_status(exc: Exception) -> Optional[int]:
|
|
87
|
+
"""Extract the HTTP status from a `websockets` handshake-rejection exception,
|
|
88
|
+
across library versions: `InvalidStatus.response.status_code` (>=11) or
|
|
89
|
+
`InvalidStatusCode.status_code` (older). Returns None for transport/network
|
|
90
|
+
errors that carry no HTTP status (those are transient -> reconnect)."""
|
|
91
|
+
resp = getattr(exc, "response", None)
|
|
92
|
+
code = getattr(resp, "status_code", None)
|
|
93
|
+
if isinstance(code, int):
|
|
94
|
+
return code
|
|
95
|
+
code = getattr(exc, "status_code", None)
|
|
96
|
+
return code if isinstance(code, int) else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --------------------------------------------------------------------------
|
|
100
|
+
# Resync recovery: channel -> REST current-state endpoint.
|
|
101
|
+
# When the server returns {"result":"resync"} for a scope, the client REST-fetches
|
|
102
|
+
# the authoritative current state (to bridge the ~5s re-hydration gap), then
|
|
103
|
+
# resubscribes WITHOUT last_seq. Mapping wire-verified against the live REST
|
|
104
|
+
# surface: every row GETs 200 with the expected shape — monitor.stats/live-stats
|
|
105
|
+
# returns fire_count/max_fires,
|
|
106
|
+
# distinct from the monitor-detail shape the other monitor.* rows return).
|
|
107
|
+
#
|
|
108
|
+
# _meta and basket.event are deliberately ABSENT: both are live-only with no ring
|
|
109
|
+
# buffer, so they never emit `resync` — resync_endpoint() returns None for them
|
|
110
|
+
# and the client skips REST-fetch (the fresh resubscribe is the only recovery).
|
|
111
|
+
# --------------------------------------------------------------------------
|
|
112
|
+
RESYNC_ENDPOINTS: Dict[str, str] = {
|
|
113
|
+
"account.state": "/v1/account/state",
|
|
114
|
+
"execution.state": "/v1/executions/{scope_id}",
|
|
115
|
+
"execution.chart": "/v1/executions/{scope_id}", # chart state via exec detail
|
|
116
|
+
"execution.list": "/v1/executions",
|
|
117
|
+
"monitor.event": "/v1/monitors/{scope_id}", # monitor-resource endpoint
|
|
118
|
+
"monitor.stats": "/v1/monitors/{scope_id}/live-stats", # per-monitor live stats
|
|
119
|
+
"monitor.state_change": "/v1/monitors/{scope_id}",
|
|
120
|
+
"monitor.alert": "/v1/monitors/{scope_id}",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resync_endpoint(channel: str, scope_id: str) -> Optional[str]:
|
|
125
|
+
"""REST current-state path for a channel's resync recovery, or None if the
|
|
126
|
+
channel has no ring buffer (no resync path) — _meta, basket.event."""
|
|
127
|
+
tmpl = RESYNC_ENDPOINTS.get(channel)
|
|
128
|
+
return None if tmpl is None else tmpl.replace("{scope_id}", scope_id)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def make_rest_fetcher(base_url: str, api_key_id: str, api_secret: str,
|
|
132
|
+
handle: Optional[str] = None, wallet: Optional[str] = None):
|
|
133
|
+
"""Build a `rest_fetch_current_state(channel, scope_id)` coroutine that GETs
|
|
134
|
+
the channel's current-state endpoint over the signed REST surface. Reference
|
|
135
|
+
implementation using httpx (imported lazily so the WS core has no hard dep);
|
|
136
|
+
bot-devs can inject their own fetcher instead. Returns parsed JSON or None."""
|
|
137
|
+
import uuid
|
|
138
|
+
|
|
139
|
+
async def fetch(channel: str, scope_id: str):
|
|
140
|
+
path = resync_endpoint(channel, scope_id)
|
|
141
|
+
if path is None:
|
|
142
|
+
return None # live-only channel; nothing to REST-fetch
|
|
143
|
+
import httpx
|
|
144
|
+
# GET nonce to dodge any same-second idempotency collisions, then HMAC-sign.
|
|
145
|
+
nonce_path = f"{path}{'&' if '?' in path else '?'}_n={uuid.uuid4().hex[:8]}"
|
|
146
|
+
ts = str(int(time.time()))
|
|
147
|
+
sig = hmac.new(api_secret.encode(), f"{ts}GET{nonce_path}".encode(),
|
|
148
|
+
hashlib.sha256).hexdigest()
|
|
149
|
+
headers = {"X-Viper-Api-Key-Id": api_key_id, "X-Viper-Signature": sig,
|
|
150
|
+
"X-Viper-Timestamp": ts}
|
|
151
|
+
if handle:
|
|
152
|
+
headers["X-Viper-Handle"] = handle
|
|
153
|
+
if wallet:
|
|
154
|
+
headers["Viper-Wallet"] = wallet
|
|
155
|
+
async with httpx.AsyncClient() as client:
|
|
156
|
+
r = await client.get(f"{base_url}{nonce_path}", headers=headers, timeout=30)
|
|
157
|
+
return r.json() if r.status_code == 200 else None
|
|
158
|
+
|
|
159
|
+
return fetch
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class _Subscription:
|
|
164
|
+
"""One (channel, scope_id) subscription and its resume cursor.
|
|
165
|
+
|
|
166
|
+
`last_seq` is tracked PER (channel, scope_id) — never a single global
|
|
167
|
+
cursor — because `seq` is monotonic per scope_id and resync is per scope.
|
|
168
|
+
"""
|
|
169
|
+
channel: str
|
|
170
|
+
scope_id: str
|
|
171
|
+
last_seq: Optional[int] = None
|
|
172
|
+
cadence_bearing: bool = False # subject to the data-staleness watchdog?
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ViperWSClient:
|
|
176
|
+
# --- Thresholds: production-proven defaults ---
|
|
177
|
+
PING_INTERVAL = 10 # client-side keepalive ping (transport liveness)
|
|
178
|
+
PING_TIMEOUT = 5 # dead-peer detection window
|
|
179
|
+
CLOSE_TIMEOUT = 3
|
|
180
|
+
WATCHDOG_INTERVAL = 10 # health check cadence
|
|
181
|
+
DATA_STALE_THRESHOLD = 45 # cadence-bearing silence -> reconnect (STALE_TIMEOUT=45)
|
|
182
|
+
MAX_RECONNECT_ATTEMPTS = 10
|
|
183
|
+
RECONNECT_BASE_DELAY = 1.0 # backoff = min(BASE * 2**(attempt-1), 30)
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
api_key_id: str,
|
|
188
|
+
api_secret: str,
|
|
189
|
+
handle: Optional[str] = None,
|
|
190
|
+
wallet: Optional[str] = None,
|
|
191
|
+
ws_url: str = "wss://api.viperexecution.com/v1/ws",
|
|
192
|
+
on_event: Optional[Callable[[dict], None]] = None,
|
|
193
|
+
on_meta: Optional[Callable[[dict], None]] = None,
|
|
194
|
+
on_terminal: Optional[Callable[[int], None]] = None,
|
|
195
|
+
on_command_result: Optional[Callable[[dict], None]] = None,
|
|
196
|
+
on_raw: Optional[Callable[[dict], None]] = None,
|
|
197
|
+
rest_fetch_current_state: Optional[
|
|
198
|
+
Callable[[str, str], Awaitable[None]]
|
|
199
|
+
] = None,
|
|
200
|
+
):
|
|
201
|
+
self._api_key_id = api_key_id
|
|
202
|
+
self._api_secret = api_secret
|
|
203
|
+
self._handle = handle
|
|
204
|
+
self._wallet = wallet
|
|
205
|
+
self._ws_url = ws_url
|
|
206
|
+
self._ws_path = "/v1/ws" if ws_url.endswith("/v1/ws") else ws_url.split("://", 1)[-1].split("/", 1)[-1]
|
|
207
|
+
if not self._ws_path.startswith("/"):
|
|
208
|
+
self._ws_path = "/" + self._ws_path
|
|
209
|
+
|
|
210
|
+
# Callbacks
|
|
211
|
+
self._on_event = on_event or (lambda f: None)
|
|
212
|
+
self._on_meta = on_meta or (lambda f: None)
|
|
213
|
+
self._on_terminal = on_terminal or (lambda code: None)
|
|
214
|
+
# Un-correlated command results (subscribe acks/errors carry no
|
|
215
|
+
# client_correlation_id — they can only be matched by channel/scope_id).
|
|
216
|
+
self._on_command_result = on_command_result or (lambda f: None)
|
|
217
|
+
# Optional raw-frame tap: fires for every frame before classification.
|
|
218
|
+
# Advanced hook for audit trails, custom metrics, or wire logging.
|
|
219
|
+
self._on_raw = on_raw or (lambda f: None)
|
|
220
|
+
self._rest_fetch_current_state = rest_fetch_current_state
|
|
221
|
+
|
|
222
|
+
# Connection state
|
|
223
|
+
self.ws = None
|
|
224
|
+
self._connected = False
|
|
225
|
+
self._connecting = False
|
|
226
|
+
self._intentionally_closed = False
|
|
227
|
+
self._terminal = False # 4013 revocation: stop forever
|
|
228
|
+
self._reconnect_attempts = 0
|
|
229
|
+
self.connect_count = 0 # total successful connects (operational metric)
|
|
230
|
+
|
|
231
|
+
# Subscriptions, keyed (channel, scope_id) -> _Subscription
|
|
232
|
+
self._subs: Dict[Tuple[str, str], _Subscription] = {}
|
|
233
|
+
|
|
234
|
+
# Command correlation: client_correlation_id -> Future(result frame)
|
|
235
|
+
self._pending: Dict[str, asyncio.Future] = {}
|
|
236
|
+
|
|
237
|
+
# Welcome-advertised limits (refreshed each connect)
|
|
238
|
+
self.ring_buffer_size: Optional[int] = None
|
|
239
|
+
self.subscriptions_max: Optional[int] = None
|
|
240
|
+
self.resolved_wallet: Optional[str] = None
|
|
241
|
+
|
|
242
|
+
# Liveness bookkeeping
|
|
243
|
+
self.last_message_at: Optional[float] = None
|
|
244
|
+
self._connect_lock: Optional[asyncio.Lock] = None
|
|
245
|
+
|
|
246
|
+
# Background tasks
|
|
247
|
+
self._reader_task: Optional[asyncio.Task] = None
|
|
248
|
+
self._watchdog_task: Optional[asyncio.Task] = None
|
|
249
|
+
|
|
250
|
+
# Welcome handshake — the reader (sole socket consumer) surfaces the
|
|
251
|
+
# first frame via this Event so connect() can absorb the limits.
|
|
252
|
+
self._welcome: Optional[dict] = None
|
|
253
|
+
self._welcome_event: Optional[asyncio.Event] = None
|
|
254
|
+
|
|
255
|
+
# ----------------------------------------------------------------- utils
|
|
256
|
+
def _lock(self) -> asyncio.Lock:
|
|
257
|
+
if self._connect_lock is None:
|
|
258
|
+
self._connect_lock = asyncio.Lock()
|
|
259
|
+
return self._connect_lock
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def is_connected(self) -> bool:
|
|
263
|
+
"""True liveness — checks the real socket state, not a flag."""
|
|
264
|
+
if not self._connected or self.ws is None:
|
|
265
|
+
return False
|
|
266
|
+
try:
|
|
267
|
+
from websockets.protocol import State
|
|
268
|
+
return self.ws.state == State.OPEN
|
|
269
|
+
except (AttributeError, ImportError):
|
|
270
|
+
try:
|
|
271
|
+
return not self.ws.closed
|
|
272
|
+
except AttributeError:
|
|
273
|
+
return self._connected
|
|
274
|
+
|
|
275
|
+
def _has_cadence_bearing_subs(self) -> bool:
|
|
276
|
+
"""Any subscription expected to push continuously? The data-staleness
|
|
277
|
+
watchdog applies ONLY to these; idle/event-only subs rely on ping/pong."""
|
|
278
|
+
return any(s.cadence_bearing for s in self._subs.values())
|
|
279
|
+
|
|
280
|
+
# --------------------------------------------------------------- connect
|
|
281
|
+
async def start(self):
|
|
282
|
+
"""Connect and run until terminal (4013) or explicit close()."""
|
|
283
|
+
await self.connect()
|
|
284
|
+
|
|
285
|
+
async def connect(self):
|
|
286
|
+
async with self._lock():
|
|
287
|
+
if self._terminal or self._connecting or self.is_connected:
|
|
288
|
+
return
|
|
289
|
+
self._connecting = True
|
|
290
|
+
self._intentionally_closed = False
|
|
291
|
+
try:
|
|
292
|
+
headers = _handshake_headers(
|
|
293
|
+
self._api_key_id, self._api_secret, self._ws_path,
|
|
294
|
+
self._handle, self._wallet,
|
|
295
|
+
)
|
|
296
|
+
# websockets API drift: additional_headers (>=12) / extra_headers (older)
|
|
297
|
+
try:
|
|
298
|
+
self.ws = await websockets.connect(
|
|
299
|
+
self._ws_url, additional_headers=headers,
|
|
300
|
+
ping_interval=self.PING_INTERVAL,
|
|
301
|
+
ping_timeout=self.PING_TIMEOUT,
|
|
302
|
+
close_timeout=self.CLOSE_TIMEOUT, open_timeout=15,
|
|
303
|
+
)
|
|
304
|
+
except TypeError:
|
|
305
|
+
self.ws = await websockets.connect(
|
|
306
|
+
self._ws_url, extra_headers=headers,
|
|
307
|
+
ping_interval=self.PING_INTERVAL,
|
|
308
|
+
ping_timeout=self.PING_TIMEOUT,
|
|
309
|
+
close_timeout=self.CLOSE_TIMEOUT, open_timeout=15,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# The reader is the SOLE consumer of the socket. Do NOT recv()
|
|
313
|
+
# here — mixing a direct recv() with the reader's `async for` on
|
|
314
|
+
# the same asyncio-API connection races and can silently starve
|
|
315
|
+
# the reader. Start the reader first; it surfaces the welcome via
|
|
316
|
+
# an Event, then connect() absorbs the welcome's limits.
|
|
317
|
+
self._welcome = None
|
|
318
|
+
self._welcome_event = asyncio.Event()
|
|
319
|
+
if self._reader_task and not self._reader_task.done():
|
|
320
|
+
self._reader_task.cancel()
|
|
321
|
+
self._reader_task = asyncio.create_task(self._reader_loop())
|
|
322
|
+
try:
|
|
323
|
+
await asyncio.wait_for(self._welcome_event.wait(), timeout=15)
|
|
324
|
+
except asyncio.TimeoutError:
|
|
325
|
+
if self._reader_task and not self._reader_task.done():
|
|
326
|
+
self._reader_task.cancel()
|
|
327
|
+
raise RuntimeError("no welcome within 15s")
|
|
328
|
+
|
|
329
|
+
welcome = self._welcome or {}
|
|
330
|
+
wdata = welcome.get("data", {}) or {}
|
|
331
|
+
self.ring_buffer_size = wdata.get("ring_buffer_size")
|
|
332
|
+
self.subscriptions_max = wdata.get("subscriptions_max")
|
|
333
|
+
self.resolved_wallet = wdata.get("wallet")
|
|
334
|
+
|
|
335
|
+
self._connected = True
|
|
336
|
+
self._connecting = False
|
|
337
|
+
self._reconnect_attempts = 0
|
|
338
|
+
self.connect_count += 1
|
|
339
|
+
self.last_message_at = time.time()
|
|
340
|
+
|
|
341
|
+
# Resubscribe everything, carrying each scope's last_seq.
|
|
342
|
+
for sub in list(self._subs.values()):
|
|
343
|
+
await self._send_subscribe(sub)
|
|
344
|
+
|
|
345
|
+
# Start watchdog (reader already started above).
|
|
346
|
+
if self._watchdog_task is None or self._watchdog_task.done():
|
|
347
|
+
self._watchdog_task = asyncio.create_task(self._watchdog_loop())
|
|
348
|
+
|
|
349
|
+
except Exception as e:
|
|
350
|
+
self._connecting = False
|
|
351
|
+
# Auth rejected AT handshake (401 bad/revoked key or signature,
|
|
352
|
+
# 403 insufficient scope): reconnecting with the same keys cannot
|
|
353
|
+
# succeed, so go terminal immediately — same disposition as a 4013
|
|
354
|
+
# close — rather than retry-hammering the full backoff budget.
|
|
355
|
+
# Anything without an HTTP status (5xx, network, DNS, timeout) is
|
|
356
|
+
# transient and falls through to the normal reconnect path.
|
|
357
|
+
status = _handshake_status(e)
|
|
358
|
+
if status in (401, 403):
|
|
359
|
+
self._terminal = True
|
|
360
|
+
self._on_terminal(status)
|
|
361
|
+
return
|
|
362
|
+
await self._schedule_reconnect(reason=f"connect_failed: {e}")
|
|
363
|
+
|
|
364
|
+
# ------------------------------------------------------------- subscribe
|
|
365
|
+
async def subscribe(self, channel: str, scope_id: str,
|
|
366
|
+
last_seq: Optional[int] = None,
|
|
367
|
+
cadence_bearing: bool = False):
|
|
368
|
+
"""Subscribe (or re-register) a (channel, scope_id). `cadence_bearing`
|
|
369
|
+
opts the sub into the data-staleness watchdog — set it for a running
|
|
370
|
+
execution.state or an actively-trading account.state, leave it False
|
|
371
|
+
for event-only/idle channels."""
|
|
372
|
+
key = (channel, scope_id)
|
|
373
|
+
sub = self._subs.get(key)
|
|
374
|
+
if sub is None:
|
|
375
|
+
sub = _Subscription(channel, scope_id, last_seq, cadence_bearing)
|
|
376
|
+
self._subs[key] = sub
|
|
377
|
+
else:
|
|
378
|
+
sub.cadence_bearing = cadence_bearing
|
|
379
|
+
if last_seq is not None:
|
|
380
|
+
sub.last_seq = last_seq
|
|
381
|
+
if self.is_connected:
|
|
382
|
+
await self._send_subscribe(sub)
|
|
383
|
+
# else: deferred — resubscribed automatically on next connect.
|
|
384
|
+
|
|
385
|
+
async def _send_subscribe(self, sub: _Subscription):
|
|
386
|
+
frame = {
|
|
387
|
+
"command": "subscribe",
|
|
388
|
+
"channel": sub.channel,
|
|
389
|
+
"scope_id": sub.scope_id,
|
|
390
|
+
"client_correlation_id": f"sub-{sub.channel}-{sub.scope_id}",
|
|
391
|
+
}
|
|
392
|
+
if sub.last_seq is not None:
|
|
393
|
+
frame["last_seq"] = sub.last_seq
|
|
394
|
+
await self.ws.send(json.dumps(frame))
|
|
395
|
+
|
|
396
|
+
# --------------------------------------------------------------- command
|
|
397
|
+
async def send_command(self, frame: dict, wait: float = 10.0) -> Optional[dict]:
|
|
398
|
+
"""Send a Tier-2/Tier-3 command and await its correlated result frame."""
|
|
399
|
+
cci = frame.get("client_correlation_id") or uuid.uuid4().hex
|
|
400
|
+
frame["client_correlation_id"] = cci
|
|
401
|
+
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
402
|
+
self._pending[cci] = fut
|
|
403
|
+
await self.ws.send(json.dumps(frame))
|
|
404
|
+
try:
|
|
405
|
+
return await asyncio.wait_for(fut, timeout=wait)
|
|
406
|
+
except asyncio.TimeoutError:
|
|
407
|
+
return None
|
|
408
|
+
finally:
|
|
409
|
+
self._pending.pop(cci, None)
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------- reader loop
|
|
412
|
+
async def _reader_loop(self):
|
|
413
|
+
try:
|
|
414
|
+
while True:
|
|
415
|
+
try:
|
|
416
|
+
raw = await asyncio.wait_for(self.ws.recv(), timeout=1.0)
|
|
417
|
+
except asyncio.TimeoutError:
|
|
418
|
+
continue
|
|
419
|
+
self.last_message_at = time.time()
|
|
420
|
+
try:
|
|
421
|
+
frame = json.loads(raw)
|
|
422
|
+
except Exception:
|
|
423
|
+
continue
|
|
424
|
+
self._on_raw(frame) # optional raw tap, fires for every frame
|
|
425
|
+
|
|
426
|
+
# 0) Welcome — first frame of every connection. Surface it via the
|
|
427
|
+
# Event so connect() can absorb limits, then keep reading.
|
|
428
|
+
if (self._welcome_event is not None
|
|
429
|
+
and not self._welcome_event.is_set()
|
|
430
|
+
and frame.get("channel") == "_meta"
|
|
431
|
+
and frame.get("event") == "welcome"):
|
|
432
|
+
self._welcome = frame
|
|
433
|
+
self._on_meta(frame)
|
|
434
|
+
self._welcome_event.set()
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
# 1) Resync result — server cannot satisfy a last_seq for a scope.
|
|
438
|
+
# Recovery is identical for all three reasons.
|
|
439
|
+
if frame.get("result") == "resync":
|
|
440
|
+
await self._handle_resync(frame)
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
# 2) Command result — correlate to a pending send_command if its
|
|
444
|
+
# client_correlation_id matches; otherwise surface it (subscribe
|
|
445
|
+
# acks/errors carry NO cci and must not be silently dropped).
|
|
446
|
+
if "result" in frame:
|
|
447
|
+
cci = frame.get("client_correlation_id")
|
|
448
|
+
fut = self._pending.get(cci) if cci else None
|
|
449
|
+
if fut and not fut.done():
|
|
450
|
+
fut.set_result(frame)
|
|
451
|
+
else:
|
|
452
|
+
self._on_command_result(frame)
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
# 3) _meta live transitions (welcome already consumed in connect()).
|
|
456
|
+
if frame.get("channel") == "_meta":
|
|
457
|
+
self._handle_meta(frame)
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
# 4) Channel event — advance that scope's cursor, route by wallet.
|
|
461
|
+
if "seq" in frame:
|
|
462
|
+
key = (frame.get("channel"), frame.get("scope_id"))
|
|
463
|
+
sub = self._subs.get(key)
|
|
464
|
+
if sub is not None:
|
|
465
|
+
sub.last_seq = frame["seq"]
|
|
466
|
+
self._on_event(frame)
|
|
467
|
+
|
|
468
|
+
except websockets.ConnectionClosed as cc:
|
|
469
|
+
code = getattr(cc, "code", None)
|
|
470
|
+
if code is None:
|
|
471
|
+
code = getattr(getattr(cc, "rcvd", None), "code", None)
|
|
472
|
+
await self._on_close(code)
|
|
473
|
+
except asyncio.CancelledError:
|
|
474
|
+
return
|
|
475
|
+
except Exception as e:
|
|
476
|
+
await self._schedule_reconnect(reason=f"reader_error: {e}")
|
|
477
|
+
|
|
478
|
+
async def _on_close(self, code: Optional[int]):
|
|
479
|
+
self._connected = False
|
|
480
|
+
# 4013 = credentials revoked — TERMINAL. Do not reconnect with these keys.
|
|
481
|
+
if code == 4013:
|
|
482
|
+
self._terminal = True
|
|
483
|
+
self._on_terminal(code)
|
|
484
|
+
return
|
|
485
|
+
# Everything else (1001 going-away, 4015 heartbeat, 1011, etc.) reconnects.
|
|
486
|
+
await self._schedule_reconnect(reason=f"closed: {code}")
|
|
487
|
+
|
|
488
|
+
# ------------------------------------------------------ resync recovery
|
|
489
|
+
async def _handle_resync(self, frame: dict):
|
|
490
|
+
"""All three resync reasons (buffer_overflow / last_seq_ahead_of_server /
|
|
491
|
+
scope_not_found) recover identically: drop the cursor, REST-fetch current
|
|
492
|
+
state, re-subscribe WITHOUT last_seq."""
|
|
493
|
+
channel = frame.get("channel")
|
|
494
|
+
scope_id = frame.get("scope_id")
|
|
495
|
+
key = (channel, scope_id)
|
|
496
|
+
sub = self._subs.get(key)
|
|
497
|
+
if sub is None:
|
|
498
|
+
return
|
|
499
|
+
sub.last_seq = None # clear cursor so the resubscribe hydrates fresh
|
|
500
|
+
if self._rest_fetch_current_state is not None:
|
|
501
|
+
try:
|
|
502
|
+
await self._rest_fetch_current_state(channel, scope_id)
|
|
503
|
+
except Exception:
|
|
504
|
+
pass
|
|
505
|
+
if self.is_connected:
|
|
506
|
+
await self._send_subscribe(sub)
|
|
507
|
+
|
|
508
|
+
# --------------------------------------------------------- _meta events
|
|
509
|
+
def _handle_meta(self, frame: dict):
|
|
510
|
+
"""`_meta` reports server<->HL upstream health, NOT server<->client health.
|
|
511
|
+
These are informational — surface them; DO NOT reconnect on them."""
|
|
512
|
+
self._on_meta(frame)
|
|
513
|
+
|
|
514
|
+
# ----------------------------------------------------------- watchdog
|
|
515
|
+
async def _watchdog_loop(self):
|
|
516
|
+
"""Two-check watchdog. Layer 2 of liveness; layer 1 is the library
|
|
517
|
+
ping/pong."""
|
|
518
|
+
while not self._intentionally_closed and not self._terminal:
|
|
519
|
+
try:
|
|
520
|
+
await asyncio.sleep(self.WATCHDOG_INTERVAL)
|
|
521
|
+
if self._intentionally_closed or self._terminal:
|
|
522
|
+
break
|
|
523
|
+
|
|
524
|
+
# Check 1: reader task died but we still think we're connected
|
|
525
|
+
# (transport alive, loop dead — the silent failure ping can't catch).
|
|
526
|
+
if (self._reader_task is not None and self._reader_task.done()
|
|
527
|
+
and self._connected):
|
|
528
|
+
await self._force_reconnect("watchdog_dead_reader")
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
# Check 2: connected + cadence-bearing subs + no data past threshold.
|
|
532
|
+
# Skipped entirely for idle/event-only subs (silence is normal).
|
|
533
|
+
if (self._connected and self.last_message_at
|
|
534
|
+
and self._has_cadence_bearing_subs()):
|
|
535
|
+
age = time.time() - self.last_message_at
|
|
536
|
+
if age > self.DATA_STALE_THRESHOLD:
|
|
537
|
+
await self._force_reconnect(f"watchdog_stale_{age:.0f}s")
|
|
538
|
+
continue
|
|
539
|
+
except asyncio.CancelledError:
|
|
540
|
+
break
|
|
541
|
+
except Exception:
|
|
542
|
+
continue
|
|
543
|
+
|
|
544
|
+
async def _force_reconnect(self, reason: str):
|
|
545
|
+
self._connected = False
|
|
546
|
+
if self.ws:
|
|
547
|
+
try:
|
|
548
|
+
await self.ws.close()
|
|
549
|
+
except Exception:
|
|
550
|
+
pass
|
|
551
|
+
self.ws = None
|
|
552
|
+
self._reconnect_attempts = 0 # watchdog trip = fresh attempt budget
|
|
553
|
+
await self.connect()
|
|
554
|
+
|
|
555
|
+
# ---------------------------------------------------------- reconnect
|
|
556
|
+
async def _schedule_reconnect(self, reason: str = ""):
|
|
557
|
+
"""Fire-and-forget reconnect as a SEPARATE task so the calling reader
|
|
558
|
+
loop can finish and reader_task.done() == True before connect() inspects
|
|
559
|
+
it (an inline await here can freeze the consumer)."""
|
|
560
|
+
if self._intentionally_closed or self._terminal:
|
|
561
|
+
return
|
|
562
|
+
self._reconnect_attempts += 1
|
|
563
|
+
if self._reconnect_attempts > self.MAX_RECONNECT_ATTEMPTS:
|
|
564
|
+
# Exhausted — surface as terminal-ish; caller decides (alert, etc.).
|
|
565
|
+
self._on_terminal(-1)
|
|
566
|
+
return
|
|
567
|
+
delay = min(self.RECONNECT_BASE_DELAY * (2 ** (self._reconnect_attempts - 1)), 30)
|
|
568
|
+
asyncio.create_task(self._do_reconnect(delay))
|
|
569
|
+
|
|
570
|
+
async def _do_reconnect(self, delay: float):
|
|
571
|
+
try:
|
|
572
|
+
await asyncio.sleep(delay)
|
|
573
|
+
await self.connect()
|
|
574
|
+
except Exception:
|
|
575
|
+
if not self._intentionally_closed and not self._terminal:
|
|
576
|
+
await asyncio.sleep(5)
|
|
577
|
+
try:
|
|
578
|
+
await self.connect()
|
|
579
|
+
except Exception:
|
|
580
|
+
pass
|
|
581
|
+
|
|
582
|
+
# -------------------------------------------------------------- close
|
|
583
|
+
async def close(self):
|
|
584
|
+
"""Clean, intentional shutdown. Stops the reconnect loop."""
|
|
585
|
+
self._intentionally_closed = True
|
|
586
|
+
if self._watchdog_task:
|
|
587
|
+
self._watchdog_task.cancel()
|
|
588
|
+
if self._reader_task:
|
|
589
|
+
self._reader_task.cancel()
|
|
590
|
+
if self.ws:
|
|
591
|
+
try:
|
|
592
|
+
await self.ws.close(code=1000)
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
self._connected = False
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# --------------------------------------------------------------------------
|
|
599
|
+
# Example — stream a running execution with full auto-reconnect/resume.
|
|
600
|
+
# This is the seed for runnable example #1.
|
|
601
|
+
# --------------------------------------------------------------------------
|
|
602
|
+
async def _example(api_key_id: str, api_secret: str, handle: str,
|
|
603
|
+
wallet: str, execution_id: str):
|
|
604
|
+
def on_event(frame):
|
|
605
|
+
ev = frame.get("event")
|
|
606
|
+
owner = (frame.get("data") or {}).get("wallet") # route by data.wallet
|
|
607
|
+
print(f"[{frame.get('channel')}/{ev}] seq={frame.get('seq')} wallet={owner}")
|
|
608
|
+
|
|
609
|
+
def on_meta(frame):
|
|
610
|
+
ev = frame.get("event")
|
|
611
|
+
if ev == "welcome":
|
|
612
|
+
c = (frame.get("data") or {}).get("connectivity", {})
|
|
613
|
+
print(f"# welcome: ring={frame['data'].get('ring_buffer_size')} "
|
|
614
|
+
f"rest={c.get('rest', {}).get('status')} ws={c.get('ws', {}).get('status')}")
|
|
615
|
+
else:
|
|
616
|
+
# upstream_rest_degraded / _resumed / upstream_ws_* — info only, no reconnect.
|
|
617
|
+
print(f"# _meta {ev}: {frame.get('data')}")
|
|
618
|
+
|
|
619
|
+
def on_terminal(code):
|
|
620
|
+
print(f"# TERMINAL (code={code}) — credentials revoked or reconnect exhausted; stopping")
|
|
621
|
+
|
|
622
|
+
# Resync recovery: a real REST fetcher built from the cited channel->endpoint
|
|
623
|
+
# mapping. On a resync result the client calls this to bridge the re-hydration
|
|
624
|
+
# gap, then resubscribes fresh.
|
|
625
|
+
rest_fetch_current_state = make_rest_fetcher(
|
|
626
|
+
base_url="https://api.viperexecution.com",
|
|
627
|
+
api_key_id=api_key_id, api_secret=api_secret, handle=handle, wallet=wallet,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
client = ViperWSClient(
|
|
631
|
+
api_key_id=api_key_id, api_secret=api_secret, handle=handle, wallet=wallet,
|
|
632
|
+
on_event=on_event, on_meta=on_meta, on_terminal=on_terminal,
|
|
633
|
+
rest_fetch_current_state=rest_fetch_current_state,
|
|
634
|
+
)
|
|
635
|
+
await client.start()
|
|
636
|
+
# Running execution => cadence-bearing => data-staleness watchdog active.
|
|
637
|
+
await client.subscribe("execution.state", execution_id, cadence_bearing=True)
|
|
638
|
+
try:
|
|
639
|
+
while not client._terminal:
|
|
640
|
+
await asyncio.sleep(1)
|
|
641
|
+
finally:
|
|
642
|
+
await client.close()
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
if __name__ == "__main__":
|
|
646
|
+
import os
|
|
647
|
+
asyncio.run(_example(
|
|
648
|
+
api_key_id=os.environ["VIPER_API_KEY"],
|
|
649
|
+
api_secret=os.environ["VIPER_API_SECRET"],
|
|
650
|
+
handle=os.environ.get("VIPER_HANDLE", ""),
|
|
651
|
+
wallet=os.environ.get("VIPER_TEST_WALLET_A", ""),
|
|
652
|
+
execution_id=os.environ.get("VIPER_EXEC_ID", ""),
|
|
653
|
+
))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: viper-execution
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Institutional-grade Python SDK for the Viper Execution trading API on Hyperliquid.
|
|
5
|
+
Project-URL: Homepage, https://www.viperexecution.com
|
|
6
|
+
Project-URL: Documentation, https://docs.viperexecution.com
|
|
7
|
+
Project-URL: Repository, https://github.com/viperexecution/viper-sdk-python
|
|
8
|
+
Project-URL: Issues, https://github.com/viperexecution/viper-sdk-python/issues
|
|
9
|
+
Author: Viper Execution
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Viper Execution
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: algotrading,hyperliquid,trading,viper,websocket
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Typing :: Typed
|
|
41
|
+
Requires-Python: >=3.10
|
|
42
|
+
Requires-Dist: httpx>=0.27.0
|
|
43
|
+
Requires-Dist: websockets>=13.0
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
46
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# Viper Execution Python SDK
|
|
50
|
+
|
|
51
|
+
Institutional-grade Python client for the [Viper Execution](https://www.viperexecution.com) trading API on Hyperliquid.
|
|
52
|
+
|
|
53
|
+
> **Status:** SDK `0.1.0` (beta). Ships the resilient WebSocket client and the resync REST-fetch mapping. The full typed REST client lands in a subsequent release. The SDK version is independent of the API version — this is SDK 0.x against API v1.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install viper-execution
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Requires Python ≥ 3.10.
|
|
62
|
+
|
|
63
|
+
## Quickstart
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import asyncio
|
|
67
|
+
from viper import ViperWSClient
|
|
68
|
+
|
|
69
|
+
async def main():
|
|
70
|
+
client = ViperWSClient(
|
|
71
|
+
api_key_id="vk_...",
|
|
72
|
+
api_secret="...",
|
|
73
|
+
handle="your-handle",
|
|
74
|
+
wallet="0x...",
|
|
75
|
+
on_event=lambda f: print(f["channel"], f.get("event")),
|
|
76
|
+
)
|
|
77
|
+
await client.start()
|
|
78
|
+
await client.subscribe("account.state", "0x...")
|
|
79
|
+
await asyncio.sleep(30)
|
|
80
|
+
await client.close()
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
See [`examples/`](examples/) for runnable scripts.
|
|
86
|
+
|
|
87
|
+
## What the WebSocket client handles for you
|
|
88
|
+
|
|
89
|
+
The `/v1/ws` stream has a number of behaviors a naive client gets wrong. `ViperWSClient` handles them as a built-in contract:
|
|
90
|
+
|
|
91
|
+
- **Liveness** — transport ping/pong plus a data-staleness watchdog; silent half-open connections are detected and reconnected.
|
|
92
|
+
- **Reconnect with resume** — on drop, it reconnects with exponential backoff and resubscribes every scope carrying its `last_seq` cursor, so you resume exactly where you left off (replay from the per-scope ring buffer).
|
|
93
|
+
- **Resync recovery** — when the server can't satisfy a cursor (`buffer_overflow` / `last_seq_ahead_of_server` / `scope_not_found`), it REST-fetches authoritative current state and resubscribes fresh.
|
|
94
|
+
- **Multi-wallet attribution** — every data frame is routed by `data.wallet`, so one socket can carry many wallets without cross-attribution. (Control markers such as `hydrated` carry no `data.wallet`; route those by `scope_id`.)
|
|
95
|
+
- **Slow hydration** — `account.state` hydration is server-slow (~5s; it gathers balance + HIP-3 collateral across all dexes, then bursts frames). The client does not mistake that for a dead stream, and neither should your application logic.
|
|
96
|
+
- **Terminal conditions** — credential revocation (close `4013`), a handshake auth rejection (HTTP `401`/`403` — revoked/invalid key or insufficient scope), or an exhausted reconnect budget all stop the loop permanently via `on_terminal` rather than reconnect-hammering.
|
|
97
|
+
|
|
98
|
+
## Callbacks
|
|
99
|
+
|
|
100
|
+
| Callback | Fires on |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `on_event(frame)` | Every classified data frame (the main path) |
|
|
103
|
+
| `on_meta(frame)` | `_meta` frames: welcome + upstream connectivity events |
|
|
104
|
+
| `on_terminal(code)` | Terminal stop. `code` is the WS close code (`4013` = credentials revoked), the handshake HTTP status (`401`/`403`), or `-1` (reconnect budget exhausted) |
|
|
105
|
+
| `on_command_result(frame)` | Subscribe acks / command errors (no correlation id) |
|
|
106
|
+
| `on_raw(frame)` | Optional advanced tap: every frame pre-classification (audit/metrics) |
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
viper/__init__.py,sha256=eTPRgkvSxFt8kiAhGQBMZdiK2TWDX25trkX4Tjx5B8s,1122
|
|
2
|
+
viper/exceptions.py,sha256=e2VH0lPRILaKQ8O2GoAOvn-9IWlze3w6E_qsqZSQHYs,1661
|
|
3
|
+
viper/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
viper/ws.py,sha256=T-1MZ3baqkok_fJFt8QUYF-_5A2MS6DRWmSPdjAqNIU,29583
|
|
5
|
+
viper_execution-0.1.0.dist-info/METADATA,sha256=zX4l71cr3tPljf83BaNzILH5r5mzO4hjggZ1lSp8uqo,5460
|
|
6
|
+
viper_execution-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
viper_execution-0.1.0.dist-info/licenses/LICENSE,sha256=WDh23SZO0COj1JOYcn-46-1WwdACnAm2SVExyhfE3oU,1072
|
|
8
|
+
viper_execution-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Viper Execution
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|