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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.