@senpi/runtime 3.0.146 → 3.0.149
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.
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +55 -0
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/quant-command.d.ts +128 -0
- package/dist/cli/quant-command.d.ts.map +1 -0
- package/dist/cli/quant-command.js +345 -0
- package/dist/cli/quant-command.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts +11 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +49 -4
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -1
- package/dist/quant/engine/quantdesk/__init__.py +19 -0
- package/dist/quant/engine/quantdesk/__main__.py +281 -0
- package/dist/quant/engine/quantdesk/_version.py +24 -0
- package/dist/quant/engine/quantdesk/benchmark.py +90 -0
- package/dist/quant/engine/quantdesk/book.py +197 -0
- package/dist/quant/engine/quantdesk/deep.py +197 -0
- package/dist/quant/engine/quantdesk/desk.py +878 -0
- package/dist/quant/engine/quantdesk/dsl.py +224 -0
- package/dist/quant/engine/quantdesk/followups.py +117 -0
- package/dist/quant/engine/quantdesk/hl_api.py +473 -0
- package/dist/quant/engine/quantdesk/market.py +290 -0
- package/dist/quant/engine/quantdesk/mcp_client.py +158 -0
- package/dist/quant/engine/quantdesk/metrics.py +409 -0
- package/dist/quant/engine/quantdesk/opportunities.py +88 -0
- package/dist/quant/engine/quantdesk/render.py +787 -0
- package/dist/quant/engine/quantdesk/roundtrips.py +124 -0
- package/dist/quant/engine/quantdesk/score.py +817 -0
- package/dist/quant/engine/quantdesk/senpi_history.py +160 -0
- package/dist/quant/engine/quantdesk/smart_money.py +354 -0
- package/dist/quant/engine/quantdesk/strategy_read.py +292 -0
- package/dist/quant/engine/quantdesk/taxonomy.py +55 -0
- package/dist/quant/engine/quantdesk/timing.py +164 -0
- package/dist/quant/engine/quantdesk/voice.py +42 -0
- package/dist/quant/engine-runner.d.ts +133 -0
- package/dist/quant/engine-runner.d.ts.map +1 -0
- package/dist/quant/engine-runner.js +306 -0
- package/dist/quant/engine-runner.js.map +1 -0
- package/dist/quant/job.d.ts +125 -0
- package/dist/quant/job.d.ts.map +1 -0
- package/dist/quant/job.js +184 -0
- package/dist/quant/job.js.map +1 -0
- package/dist/quant/register.d.ts +53 -0
- package/dist/quant/register.d.ts.map +1 -0
- package/dist/quant/register.js +253 -0
- package/dist/quant/register.js.map +1 -0
- package/dist/quant/registry.d.ts +105 -0
- package/dist/quant/registry.d.ts.map +1 -0
- package/dist/quant/registry.js +248 -0
- package/dist/quant/registry.js.map +1 -0
- package/dist/quant/types.d.ts +216 -0
- package/dist/quant/types.d.ts.map +1 -0
- package/dist/quant/types.js +121 -0
- package/dist/quant/types.js.map +1 -0
- package/dist/skills-manager/manifest.js +1 -1
- package/dist/skills-manager/skills-manifest.json +73 -16
- package/dist/widgets/index.d.ts +8 -1
- package/dist/widgets/index.d.ts.map +1 -1
- package/dist/widgets/index.js +1 -0
- package/dist/widgets/index.js.map +1 -1
- package/dist/widgets/registry.d.ts +8 -0
- package/dist/widgets/registry.d.ts.map +1 -1
- package/dist/widgets/registry.js +26 -1
- package/dist/widgets/registry.js.map +1 -1
- package/dist/widgets/tool.d.ts +14 -4
- package/dist/widgets/tool.d.ts.map +1 -1
- package/dist/widgets/tool.js +36 -23
- package/dist/widgets/tool.js.map +1 -1
- package/dist/widgets/turn-cap.d.ts +33 -0
- package/dist/widgets/turn-cap.d.ts.map +1 -0
- package/dist/widgets/turn-cap.js +119 -0
- package/dist/widgets/turn-cap.js.map +1 -0
- package/dist/widgets/types.d.ts +33 -5
- package/dist/widgets/types.d.ts.map +1 -1
- package/dist/widgets/types.js +4 -3
- package/dist/widgets/types.js.map +1 -1
- package/dist/widgets/widgets/fund-user-wallet.d.ts.map +1 -1
- package/dist/widgets/widgets/fund-user-wallet.js +1 -0
- package/dist/widgets/widgets/fund-user-wallet.js.map +1 -1
- package/dist/widgets/widgets/quant-desk-recommendations.d.ts +52 -0
- package/dist/widgets/widgets/quant-desk-recommendations.d.ts.map +1 -0
- package/dist/widgets/widgets/quant-desk-recommendations.js +278 -0
- package/dist/widgets/widgets/quant-desk-recommendations.js.map +1 -0
- package/openclaw.plugin.json +12 -2
- package/package.json +4 -3
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Hyperliquid public Info API — every read the desk needs for ANY address, no auth.
|
|
3
|
+
|
|
4
|
+
`POST https://api.hyperliquid.xyz/info` with `{"type": ..., "user": "0x..."}`. Stdlib only. Every call
|
|
5
|
+
is cached on disk (default: <tempdir>/quant-desk/cache) so a re-run inside the TTL costs nothing, and
|
|
6
|
+
a `HLFixture` serves recorded responses for tests and `--fixture` runs. Paging: `userFillsByTime`
|
|
7
|
+
returns at most 2000 fills and `userFunding` at most 500 rows per call — both are paged by
|
|
8
|
+
`startTime = last.time + 1` until a short page.
|
|
9
|
+
"""
|
|
10
|
+
# Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
|
|
11
|
+
import email.utils
|
|
12
|
+
import datetime
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import random
|
|
16
|
+
import socket
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
import threading
|
|
23
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
24
|
+
|
|
25
|
+
INFO_URL = os.environ.get("HL_INFO_URL", "https://api.hyperliquid.xyz/info")
|
|
26
|
+
LEADERBOARD_URL = os.environ.get("HL_LEADERBOARD_URL", "https://stats-data.hyperliquid.xyz/Mainnet/leaderboard")
|
|
27
|
+
DAY_MS = 86_400_000
|
|
28
|
+
FILLS_PAGE = 2000
|
|
29
|
+
FUNDING_PAGE = 500
|
|
30
|
+
MAX_FILL_PAGES = 12 # 24k fills — past that the book is a market maker's, not a trader's
|
|
31
|
+
MAX_TWAP_PAGES = 25 # TWAP slices are tiny and numerous; 50k of them is a bot
|
|
32
|
+
MAX_FUNDING_PAGES = 40
|
|
33
|
+
RETRIES = 4 # 5xx and transport faults (timeout / reset) — see _request
|
|
34
|
+
# 429 gets its own, longer budget. It is not a fault, it is the venue's per-IP weight bucket, and the
|
|
35
|
+
# bucket refills on a ~minute. Four tries and 10.5s of backoff killed 3 of 7 desks run in parallel —
|
|
36
|
+
# and a 15-wallet round is exactly that shape. 6 tries capped at 20s gives ~63s, one refill window.
|
|
37
|
+
# (H3, @0xsarvesh #718.)
|
|
38
|
+
RATE_LIMIT_RETRIES = 6
|
|
39
|
+
RATE_LIMIT_MAX_SLEEP_S = 20.0
|
|
40
|
+
# Retryable beyond 429: a 90-day desk makes 100-200 single-attempt requests, so one 503 or one reset
|
|
41
|
+
# socket aborted the whole run with exit 1. SKILL.md's "fails open" was only ever true of the
|
|
42
|
+
# OPTIONAL layers. (@0xsarvesh, #718.)
|
|
43
|
+
RETRY_CODES = (429, 500, 502, 503, 504)
|
|
44
|
+
BACKOFF_S = 1.5
|
|
45
|
+
DEFAULT_CACHE = os.path.join(tempfile.gettempdir(), "quant-desk", "cache")
|
|
46
|
+
# The cache key is sha1(request body), and `now_ms` goes INTO the body of every expensive read —
|
|
47
|
+
# candleSnapshot's startTime/endTime, userFillsByTime, userFunding, the ledger. At millisecond
|
|
48
|
+
# resolution every run got a unique key, so the reads that dominate runtime NEVER hit the cache the
|
|
49
|
+
# module docstring promises. A desk killed at 120s therefore cost full price again on the re-run,
|
|
50
|
+
# and the agent that re-ran it three more times paid four times over — into the same per-IP rate
|
|
51
|
+
# bucket, which is what actually produced the 429s. (a live desk, 2026-09-23: five concurrent runs on
|
|
52
|
+
# one wallet, three dead.) Flooring "now" to a 5-minute bucket makes every run inside that bucket
|
|
53
|
+
# share keys, well within the 600s/900s TTLs. The cost is that the window can end up to 5 minutes
|
|
54
|
+
# stale — nothing, on 90 days, and the live book is a separate 120s-TTL read that does not use it.
|
|
55
|
+
NOW_BUCKET_MS = 300_000
|
|
56
|
+
TTL = {"metaAndAssetCtxs::xyz": 120, "clearinghouseState": 120, "frontendOpenOrders": 120, "metaAndAssetCtxs": 120, "candleSnapshot": 900,
|
|
57
|
+
"userFees": 3600, "portfolio": 600, "userNonFundingLedgerUpdates": 600, "userFillsByTime": 600,
|
|
58
|
+
"userFunding": 600, "leaderboard": 6 * 3600,
|
|
59
|
+
# what an address IS does not change on a desk's timescale
|
|
60
|
+
"userRole": 7 * 24 * 3600, "vaultDetails": 7 * 24 * 3600}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _retry_after(headers):
|
|
64
|
+
"""Seconds to wait from a Retry-After header, in either legal form.
|
|
65
|
+
|
|
66
|
+
RFC 7231 allows delta-seconds OR an HTTP-date, and Hyperliquid's edge has sent both. `float()`
|
|
67
|
+
parses only the first, and the ValueError it raises on the second escapes from INSIDE the 429
|
|
68
|
+
handler — so the one header that tells us when the bucket refills turned a retryable rate-limit
|
|
69
|
+
into a hard crash. (@danielmbirochi, #718.)
|
|
70
|
+
"""
|
|
71
|
+
v = (headers or {}).get("Retry-After")
|
|
72
|
+
if not v:
|
|
73
|
+
return 0.0
|
|
74
|
+
try:
|
|
75
|
+
return max(0.0, float(v))
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
pass
|
|
78
|
+
try:
|
|
79
|
+
when = email.utils.parsedate_to_datetime(v)
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
return 0.0
|
|
82
|
+
if when is None:
|
|
83
|
+
return 0.0
|
|
84
|
+
if when.tzinfo is None:
|
|
85
|
+
when = when.replace(tzinfo=datetime.timezone.utc)
|
|
86
|
+
return max(0.0, (when - datetime.datetime.now(datetime.timezone.utc)).total_seconds())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cached(path):
|
|
90
|
+
"""A cache hit, or None if the file cannot be read as JSON.
|
|
91
|
+
|
|
92
|
+
Cache writes are atomic now, but a file written before that, or truncated by a full disk, used to
|
|
93
|
+
be terminal: every later run re-read the same corrupt bytes and died on JSONDecodeError instead
|
|
94
|
+
of refetching. A cache is an optimisation — it is never a reason to fail. (@danielmbirochi, #718.)
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
with open(path) as fh:
|
|
98
|
+
return json.load(fh)
|
|
99
|
+
except (OSError, ValueError):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _atomic_json(path, obj):
|
|
104
|
+
"""Write-then-rename. A half-written cache file reads as JSONDecodeError, which is not an HLError,
|
|
105
|
+
so desk.py's handler misses it and the poisoned file stays hot for its whole TTL. Two desks run in
|
|
106
|
+
parallel was enough to hit it. (@0xsarvesh, #718.)"""
|
|
107
|
+
d = os.path.dirname(path) or "."
|
|
108
|
+
os.makedirs(d, exist_ok=True)
|
|
109
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".hl.", suffix=".json")
|
|
110
|
+
try:
|
|
111
|
+
with os.fdopen(fd, "w") as fh:
|
|
112
|
+
json.dump(obj, fh)
|
|
113
|
+
os.replace(tmp, path)
|
|
114
|
+
finally:
|
|
115
|
+
if os.path.exists(tmp):
|
|
116
|
+
os.unlink(tmp)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class HLError(Exception):
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_perp(coin):
|
|
124
|
+
"""Spot fills name the pair (`@107`, `PURR/USDC`); perps are bare names or `xyz:`-prefixed."""
|
|
125
|
+
return not (coin.startswith("@") or "/" in coin)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class HL:
|
|
129
|
+
"""Live client with a disk cache. `cache_dir=None` disables caching."""
|
|
130
|
+
|
|
131
|
+
progress = None # optional callable(str): the desk streams sub-steps of the long fetches through it
|
|
132
|
+
|
|
133
|
+
def __init__(self, cache_dir=DEFAULT_CACHE, timeout=60, now_ms=None):
|
|
134
|
+
self.cache_dir = cache_dir
|
|
135
|
+
self.timeout = timeout
|
|
136
|
+
self.now_ms = now_ms or (int(time.time() * 1000) // NOW_BUCKET_MS) * NOW_BUCKET_MS
|
|
137
|
+
self.calls = 0
|
|
138
|
+
if cache_dir:
|
|
139
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
140
|
+
|
|
141
|
+
# ---- transport ----
|
|
142
|
+
def info(self, body):
|
|
143
|
+
key = hashlib.sha1(json.dumps(body, sort_keys=True).encode()).hexdigest()
|
|
144
|
+
ttl = TTL.get(body.get("type"), 300)
|
|
145
|
+
path = os.path.join(self.cache_dir, key + ".json") if self.cache_dir else None
|
|
146
|
+
if path and os.path.exists(path) and time.time() - os.path.getmtime(path) < ttl:
|
|
147
|
+
hit = _cached(path)
|
|
148
|
+
if hit is not None:
|
|
149
|
+
return hit
|
|
150
|
+
req = urllib.request.Request(INFO_URL, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
|
|
151
|
+
data = None
|
|
152
|
+
for attempt in range(max(RETRIES, RATE_LIMIT_RETRIES)):
|
|
153
|
+
self.calls += 1
|
|
154
|
+
try:
|
|
155
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
156
|
+
data = json.load(r)
|
|
157
|
+
break
|
|
158
|
+
except urllib.error.HTTPError as e:
|
|
159
|
+
if e.code == 429 and attempt < RATE_LIMIT_RETRIES - 1:
|
|
160
|
+
# honour Retry-After when the venue sends it; it knows when the bucket refills
|
|
161
|
+
wait = _retry_after(e.headers) or min(
|
|
162
|
+
RATE_LIMIT_MAX_SLEEP_S, BACKOFF_S * (2 ** attempt))
|
|
163
|
+
# Jitter, because the bucket is per-IP and shared: without it every desk in a
|
|
164
|
+
# parallel round backs off in lockstep and collides again on the same refill.
|
|
165
|
+
# Budget alone took a 7-way round from 4/7 to 6/7; jitter is what decorrelates
|
|
166
|
+
# the survivors.
|
|
167
|
+
time.sleep(min(wait, RATE_LIMIT_MAX_SLEEP_S) * (0.5 + random.random()))
|
|
168
|
+
continue
|
|
169
|
+
if e.code in RETRY_CODES and e.code != 429 and attempt < RETRIES - 1:
|
|
170
|
+
time.sleep(BACKOFF_S * (2 ** attempt))
|
|
171
|
+
continue
|
|
172
|
+
raise HLError(f"{body.get('type')}: HTTP {e.code}") from e
|
|
173
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError, socket.timeout) as e:
|
|
174
|
+
# a reset socket or a read timeout is not an answer, it is the absence of one
|
|
175
|
+
if attempt < RETRIES - 1:
|
|
176
|
+
time.sleep(BACKOFF_S * (2 ** attempt))
|
|
177
|
+
continue
|
|
178
|
+
raise HLError(f"{body.get('type')}: {e}") from e
|
|
179
|
+
except Exception as e: # noqa: BLE001 — the caller decides whether this read was essential
|
|
180
|
+
raise HLError(f"{body.get('type')}: {e}") from e
|
|
181
|
+
if path:
|
|
182
|
+
_atomic_json(path, data)
|
|
183
|
+
return data
|
|
184
|
+
|
|
185
|
+
# ---- trader-level reads ----
|
|
186
|
+
def fills(self, addr, start_ms, end_ms=None):
|
|
187
|
+
end_ms = end_ms or self.now_ms
|
|
188
|
+
out, t = [], start_ms
|
|
189
|
+
for _ in range(MAX_FILL_PAGES):
|
|
190
|
+
page = self.info({"type": "userFillsByTime", "user": addr, "startTime": t, "endTime": end_ms, "aggregateByTime": True})
|
|
191
|
+
if not page:
|
|
192
|
+
break
|
|
193
|
+
out += page
|
|
194
|
+
self._tick(f"[quant-desk] · {len(out):,} fills scanned …")
|
|
195
|
+
if len(page) < FILLS_PAGE:
|
|
196
|
+
break
|
|
197
|
+
t = page[-1]["time"] + 1
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
def _tick(self, msg):
|
|
201
|
+
if self.progress:
|
|
202
|
+
self.progress(msg)
|
|
203
|
+
|
|
204
|
+
def twap_slices(self, addr, start_ms, end_ms=None):
|
|
205
|
+
"""TWAP executions are NOT in `userFillsByTime` — Hyperliquid reports them separately, one record
|
|
206
|
+
per slice, wrapped as {fill, twapId}. Without them an active trader's book shows phantom position
|
|
207
|
+
jumps (about half the taker volume on the accounts checked). Same 2000-per-page paging."""
|
|
208
|
+
end_ms = end_ms or self.now_ms
|
|
209
|
+
out, t = [], start_ms
|
|
210
|
+
for _ in range(MAX_TWAP_PAGES):
|
|
211
|
+
try:
|
|
212
|
+
page = self.info({"type": "userTwapSliceFillsByTime", "user": addr, "startTime": t, "endTime": end_ms})
|
|
213
|
+
except HLError:
|
|
214
|
+
break # the endpoint is newer than the rest; a wallet with no TWAPs may 4xx on some deployments
|
|
215
|
+
if not page:
|
|
216
|
+
break
|
|
217
|
+
for x in page:
|
|
218
|
+
f = dict(x.get("fill") or x)
|
|
219
|
+
f["twapId"] = x.get("twapId", f.get("twapId"))
|
|
220
|
+
out.append(f)
|
|
221
|
+
self._tick(f"[quant-desk] · {len(out):,} TWAP slices scanned …")
|
|
222
|
+
if len(page) < FILLS_PAGE:
|
|
223
|
+
break
|
|
224
|
+
t = max(f["time"] for f in out) + 1
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
def funding(self, addr, start_ms, end_ms=None):
|
|
228
|
+
end_ms = end_ms or self.now_ms
|
|
229
|
+
out, t = [], start_ms
|
|
230
|
+
for _ in range(MAX_FUNDING_PAGES):
|
|
231
|
+
page = self.info({"type": "userFunding", "user": addr, "startTime": t, "endTime": end_ms})
|
|
232
|
+
if not page:
|
|
233
|
+
break
|
|
234
|
+
out += page
|
|
235
|
+
if len(page) < FUNDING_PAGE:
|
|
236
|
+
break
|
|
237
|
+
t = page[-1]["time"] + 1
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
def trader(self, addr, days=90, lookback_days=60):
|
|
241
|
+
"""Everything about one address: fills back to `days + lookback_days` (so round trips that opened
|
|
242
|
+
before the window are complete), funding and ledger over the window, live state and orders."""
|
|
243
|
+
start = self.now_ms - (days + lookback_days) * DAY_MS
|
|
244
|
+
win_start = self.now_ms - days * DAY_MS
|
|
245
|
+
return {
|
|
246
|
+
"address": addr, "now_ms": self.now_ms, "window_start_ms": win_start, "fetch_start_ms": start, "days": days,
|
|
247
|
+
"clearinghouseState": self.info({"type": "clearinghouseState", "user": addr}),
|
|
248
|
+
"frontendOpenOrders": self.info({"type": "frontendOpenOrders", "user": addr}),
|
|
249
|
+
"clearinghouseState_xyz": self._optional({"type": "clearinghouseState", "user": addr, "dex": "xyz"}),
|
|
250
|
+
"frontendOpenOrders_xyz": self._optional({"type": "frontendOpenOrders", "user": addr, "dex": "xyz"}) or [],
|
|
251
|
+
"spotClearinghouseState": self._optional({"type": "spotClearinghouseState", "user": addr}),
|
|
252
|
+
"fills": merge_fills(self.fills(addr, start), self.twap_slices(addr, start)),
|
|
253
|
+
"userFunding": self.funding(addr, win_start),
|
|
254
|
+
"userFees": self.info({"type": "userFees", "user": addr}),
|
|
255
|
+
# HIP-3 assets bill on their own schedule. Pricing xyz: volume with the main-dex rate
|
|
256
|
+
# overstated one book's fees 4.01x (B2, #718).
|
|
257
|
+
"userFees_xyz": self._optional({"type": "userFees", "user": addr, "dex": "xyz"}),
|
|
258
|
+
"portfolio": self.info({"type": "portfolio", "user": addr}),
|
|
259
|
+
"ledger": self.info({"type": "userNonFundingLedgerUpdates", "user": addr, "startTime": win_start}),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
def subject(self, addr):
|
|
263
|
+
"""What this address IS, before the desk spends 200 reads assuming it is a trader.
|
|
264
|
+
|
|
265
|
+
`userRole` is Hyperliquid's own answer and costs one call: `user`, `vault`, `agent`,
|
|
266
|
+
`subAccount` or `missing`. It is not a heuristic and it does not guess — every senpi
|
|
267
|
+
strategy wallet and every ordinary whale comes back `user`.
|
|
268
|
+
|
|
269
|
+
This exists because of a real desk: a reader asking for their Hyperliquid score had the
|
|
270
|
+
agent point the desk at `0x31ca…974b`, which is **HLP Strategy B** — a component market
|
|
271
|
+
making strategy inside Hyperliquid's own HLP vault. 13,722 fills, 172 coins, 177 open
|
|
272
|
+
positions, 76% resting, zero fees paid. The desk spent two minutes on it and was killed
|
|
273
|
+
mid-run, and had it finished, every sentence would have been wrong: there is no entry
|
|
274
|
+
thesis to time on a quoting engine, no stop to place, and no "giving back 46% of a
|
|
275
|
+
winner's peak". One call answers it, and answers it better.
|
|
276
|
+
|
|
277
|
+
Fails OPEN: an address we cannot classify is read as an ordinary trader, which is what it
|
|
278
|
+
almost always is.
|
|
279
|
+
"""
|
|
280
|
+
try:
|
|
281
|
+
role = (self.info({"type": "userRole", "user": addr}) or {}).get("role") or "user"
|
|
282
|
+
except HLError:
|
|
283
|
+
return {"role": "user", "unverified": True}
|
|
284
|
+
out = {"role": role}
|
|
285
|
+
if role == "vault":
|
|
286
|
+
try:
|
|
287
|
+
d = self.info({"type": "vaultDetails", "vaultAddress": addr}) or {}
|
|
288
|
+
out["name"] = d.get("name")
|
|
289
|
+
out["description"] = d.get("description")
|
|
290
|
+
out["leader"] = d.get("leader")
|
|
291
|
+
except HLError:
|
|
292
|
+
pass
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
def _optional(self, body):
|
|
296
|
+
try:
|
|
297
|
+
return self.info(body)
|
|
298
|
+
except HLError:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
# ---- market-level reads ----
|
|
302
|
+
def meta(self, dex=""):
|
|
303
|
+
return self.info({"type": "metaAndAssetCtxs", "dex": dex} if dex else {"type": "metaAndAssetCtxs"})
|
|
304
|
+
|
|
305
|
+
def candles(self, coins, days=91, interval="1h", workers=6):
|
|
306
|
+
"""Hourly candles per coin over `days`, fetched in parallel; a coin that fails maps to None."""
|
|
307
|
+
start = self.now_ms - days * DAY_MS
|
|
308
|
+
todo = sorted(set(coins)); done = [0]; lock = threading.Lock()
|
|
309
|
+
def one(c):
|
|
310
|
+
try:
|
|
311
|
+
rows = self.info({"type": "candleSnapshot", "req": {"coin": c, "interval": interval, "startTime": start, "endTime": self.now_ms}})
|
|
312
|
+
out = c, [[r["t"], float(r["o"]), float(r["h"]), float(r["l"]), float(r["c"]), float(r["v"])] for r in rows]
|
|
313
|
+
except Exception: # noqa: BLE001
|
|
314
|
+
out = c, None
|
|
315
|
+
with lock:
|
|
316
|
+
done[0] += 1; k = done[0]
|
|
317
|
+
if k % 40 == 0 or k == len(todo):
|
|
318
|
+
self._tick(f"[quant-desk] · reading the tape: {k} of {len(todo)} coins{' · daily' if interval != '1h' else ''} …")
|
|
319
|
+
return out
|
|
320
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
321
|
+
return dict(ex.map(one, todo))
|
|
322
|
+
|
|
323
|
+
def states(self, addrs, workers=6):
|
|
324
|
+
def one(a):
|
|
325
|
+
try:
|
|
326
|
+
return a, self.info({"type": "clearinghouseState", "user": a})
|
|
327
|
+
except Exception: # noqa: BLE001
|
|
328
|
+
return a, None
|
|
329
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
330
|
+
return dict(ex.map(one, addrs))
|
|
331
|
+
|
|
332
|
+
def leaderboard(self):
|
|
333
|
+
"""Hyperliquid's public leaderboard (every account with a window performance; ~40 MB), cached."""
|
|
334
|
+
path = os.path.join(self.cache_dir, "leaderboard.json") if self.cache_dir else None
|
|
335
|
+
if path and os.path.exists(path) and time.time() - os.path.getmtime(path) < TTL["leaderboard"]:
|
|
336
|
+
hit = _cached(path)
|
|
337
|
+
if hit is not None:
|
|
338
|
+
return hit
|
|
339
|
+
self.calls += 1
|
|
340
|
+
try:
|
|
341
|
+
with urllib.request.urlopen(urllib.request.Request(LEADERBOARD_URL), timeout=max(self.timeout, 120)) as r:
|
|
342
|
+
data = json.load(r)
|
|
343
|
+
except Exception as e: # noqa: BLE001
|
|
344
|
+
raise HLError(f"leaderboard: {e}") from e
|
|
345
|
+
if path:
|
|
346
|
+
_atomic_json(path, data)
|
|
347
|
+
return data
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class HLFixture(HL):
|
|
351
|
+
"""Serves recorded responses: `hl::<type>` or `hl::<type>::<key>` where key is the address (lower-case)
|
|
352
|
+
for trader reads, the coin for candles, and `leaderboard` for the leaderboard. Never touches the network."""
|
|
353
|
+
|
|
354
|
+
def __init__(self, recorded, now_ms=None):
|
|
355
|
+
super().__init__(cache_dir=None, now_ms=now_ms or recorded.get("now_ms"))
|
|
356
|
+
self._r = recorded
|
|
357
|
+
|
|
358
|
+
def info(self, body):
|
|
359
|
+
t = body.get("type")
|
|
360
|
+
req = body.get("req") or {}
|
|
361
|
+
user = str(body.get("user", "")).lower(); dex = body.get("dex", "")
|
|
362
|
+
if user and dex and f"hl::{t}::{user}::{dex}" not in self._r and t in ("clearinghouseState", "frontendOpenOrders"):
|
|
363
|
+
raise HLError(f"fixture has no {t} for dex {dex}") # a fixture without an xyz view = an account with no xyz collateral
|
|
364
|
+
for key in (f"hl::{t}::{user}::{dex}" if dex else "", f"hl::{t}::{user}", f"hl::{t}::{req.get('coin', '')}::{req.get('interval', '')}", f"hl::{t}::{req.get('coin', '')}",
|
|
365
|
+
f"hl::{t}::{dex}", f"hl::{t}"):
|
|
366
|
+
if not key:
|
|
367
|
+
continue
|
|
368
|
+
if key in self._r:
|
|
369
|
+
data = self._r[key]
|
|
370
|
+
if t in ("userFillsByTime", "userTwapSliceFillsByTime", "userFunding") and isinstance(data, list):
|
|
371
|
+
# emulate the live endpoint: the time window, ascending, one page
|
|
372
|
+
lo, hi = body.get("startTime", 0), body.get("endTime", 1 << 62)
|
|
373
|
+
tm = (lambda x: (x.get("fill") or x)["time"])
|
|
374
|
+
rows = sorted((x for x in data if lo <= tm(x) <= hi), key=tm)
|
|
375
|
+
return rows[:FILLS_PAGE if t != "userFunding" else FUNDING_PAGE]
|
|
376
|
+
return data
|
|
377
|
+
raise HLError(f"fixture has no {t}")
|
|
378
|
+
|
|
379
|
+
def leaderboard(self):
|
|
380
|
+
if "hl::leaderboard" not in self._r:
|
|
381
|
+
raise HLError("fixture has no leaderboard")
|
|
382
|
+
return self._r["hl::leaderboard"]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def merge_fills(fills, slices):
|
|
386
|
+
"""One stream, de-duplicated on the trade id, time-ordered."""
|
|
387
|
+
by = {f["tid"]: f for f in fills}
|
|
388
|
+
for f in slices:
|
|
389
|
+
by.setdefault(f["tid"], f)
|
|
390
|
+
return sorted(by.values(), key=lambda f: (f["time"], f["tid"]))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ---- leaderboard helpers ----
|
|
394
|
+
def _window(row, name):
|
|
395
|
+
for k, v in row.get("windowPerformances") or []:
|
|
396
|
+
if k == name:
|
|
397
|
+
return {kk: float(vv) for kk, vv in v.items()}
|
|
398
|
+
return {}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def weekly_rank(leaderboard, addr):
|
|
402
|
+
"""Rank by weekly PnL among every account on the public leaderboard, plus Hyperliquid's own ROI windows."""
|
|
403
|
+
rows = leaderboard.get("leaderboardRows") or []
|
|
404
|
+
addr = addr.lower()
|
|
405
|
+
pnls = [(r["ethAddress"].lower(), _window(r, "week").get("pnl", 0.0)) for r in rows]
|
|
406
|
+
mine = next((p for a, p in pnls if a == addr), None)
|
|
407
|
+
if mine is None:
|
|
408
|
+
return None
|
|
409
|
+
rank = 1 + sum(1 for _, p in pnls if p > mine)
|
|
410
|
+
me = next(r for r in rows if r["ethAddress"].lower() == addr)
|
|
411
|
+
windows = {w: _window(me, w) for w in ("day", "week", "month", "allTime")}
|
|
412
|
+
ranks = {"week": rank}
|
|
413
|
+
for w in ("month", "allTime"): # one bad week must not pass for the trader: the month and all-time ranks ride along
|
|
414
|
+
mv = windows[w].get("pnl")
|
|
415
|
+
if mv is not None:
|
|
416
|
+
ranks[w] = 1 + sum(1 for r in rows if _window(r, w).get("pnl", 0.0) > mv)
|
|
417
|
+
return {"rank": rank, "of": len(pnls), "top_pct": 100.0 * rank / len(pnls), "week_pnl": mine, "ranks": ranks,
|
|
418
|
+
"windows": windows, "account_value": float(me.get("accountValue") or 0)}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# The bands a reader actually thinks in. "Find me traders to analyse" is a size question first —
|
|
422
|
+
# a $9k book and a $9M book teach different lessons — then a style question.
|
|
423
|
+
FIND_BANDS = {
|
|
424
|
+
"5k-10k": (5_000.0, 10_000.0),
|
|
425
|
+
"10k-25k": (10_000.0, 25_000.0),
|
|
426
|
+
"25k-100k": (25_000.0, 100_000.0),
|
|
427
|
+
"100k-1m": (100_000.0, 1_000_000.0),
|
|
428
|
+
"whales": (1_000_000.0, float("inf")),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def find_traders(leaderboard, band=None, window="month", n=8, min_volume=250_000.0, losers=False):
|
|
433
|
+
"""Candidate wallets to run the desk on, from public leaderboard data alone.
|
|
434
|
+
|
|
435
|
+
`band` is an account-size bracket (see FIND_BANDS) — the first thing a reader picks, because a
|
|
436
|
+
$9k book and a $9M book teach different lessons. `window` is "week" for who is hot right now or
|
|
437
|
+
"month"/"allTime" for who has held up. `min_volume` drops the vault and yield accounts that hold
|
|
438
|
+
equity but never trade — they render as an empty desk.
|
|
439
|
+
|
|
440
|
+
Returns dicts, not bare addresses: whoever is choosing needs to see why each one is on the list.
|
|
441
|
+
"""
|
|
442
|
+
lo, hi = FIND_BANDS.get(band or "", (0.0, float("inf")))
|
|
443
|
+
out = []
|
|
444
|
+
for r in leaderboard.get("leaderboardRows") or []:
|
|
445
|
+
av = float(r.get("accountValue") or 0)
|
|
446
|
+
if not (lo <= av <= hi):
|
|
447
|
+
continue
|
|
448
|
+
w = _window(r, window)
|
|
449
|
+
if w.get("vlm", 0) < min_volume:
|
|
450
|
+
continue
|
|
451
|
+
pnl = w.get("pnl", 0)
|
|
452
|
+
if (pnl >= 0) if losers else (pnl <= 0):
|
|
453
|
+
continue
|
|
454
|
+
out.append(dict(address=r["ethAddress"], account_value=av, pnl=pnl,
|
|
455
|
+
roi=w.get("roi", 0), volume=w.get("vlm", 0), window=window,
|
|
456
|
+
turnover=(w.get("vlm", 0) / av) if av > 0 else 0.0))
|
|
457
|
+
out.sort(key=lambda x: x["pnl"], reverse=not losers)
|
|
458
|
+
return out[:n]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def public_cohort(leaderboard, n=40, min_account_value=1_000_000.0, min_month_roi=0.05, min_month_volume=1_000_000.0):
|
|
462
|
+
"""The whale cohort from public data alone: large accounts that are up on the month and all-time AND
|
|
463
|
+
actually trade (monthly volume ≥ $1M — the leaderboard also lists vault and yield accounts with equity
|
|
464
|
+
but no fills), ranked by monthly PnL. The fallback when senpi discovery is unavailable."""
|
|
465
|
+
rows = leaderboard.get("leaderboardRows") or []
|
|
466
|
+
keep = []
|
|
467
|
+
for r in rows:
|
|
468
|
+
m, a = _window(r, "month"), _window(r, "allTime")
|
|
469
|
+
if (float(r.get("accountValue") or 0) >= min_account_value and m.get("pnl", 0) > 0 and a.get("pnl", 0) > 0
|
|
470
|
+
and m.get("roi", 0) >= min_month_roi and m.get("vlm", 0) >= min_month_volume):
|
|
471
|
+
keep.append((m["pnl"], r["ethAddress"]))
|
|
472
|
+
keep.sort(reverse=True)
|
|
473
|
+
return [a for _, a in keep[:n]]
|