@senpi/runtime 3.0.148 → 3.0.150

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.
Files changed (88) hide show
  1. package/dist/cli/guide-commands.d.ts.map +1 -1
  2. package/dist/cli/guide-commands.js +55 -0
  3. package/dist/cli/guide-commands.js.map +1 -1
  4. package/dist/cli/quant-command.d.ts +128 -0
  5. package/dist/cli/quant-command.d.ts.map +1 -0
  6. package/dist/cli/quant-command.js +345 -0
  7. package/dist/cli/quant-command.js.map +1 -0
  8. package/dist/cli/senpi-commands.d.ts +11 -0
  9. package/dist/cli/senpi-commands.d.ts.map +1 -1
  10. package/dist/cli/senpi-commands.js +49 -4
  11. package/dist/cli/senpi-commands.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +40 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/quant/engine/quantdesk/__init__.py +19 -0
  16. package/dist/quant/engine/quantdesk/__main__.py +281 -0
  17. package/dist/quant/engine/quantdesk/_version.py +24 -0
  18. package/dist/quant/engine/quantdesk/benchmark.py +90 -0
  19. package/dist/quant/engine/quantdesk/book.py +197 -0
  20. package/dist/quant/engine/quantdesk/deep.py +197 -0
  21. package/dist/quant/engine/quantdesk/desk.py +878 -0
  22. package/dist/quant/engine/quantdesk/dsl.py +224 -0
  23. package/dist/quant/engine/quantdesk/followups.py +117 -0
  24. package/dist/quant/engine/quantdesk/hl_api.py +473 -0
  25. package/dist/quant/engine/quantdesk/market.py +290 -0
  26. package/dist/quant/engine/quantdesk/mcp_client.py +158 -0
  27. package/dist/quant/engine/quantdesk/metrics.py +409 -0
  28. package/dist/quant/engine/quantdesk/opportunities.py +88 -0
  29. package/dist/quant/engine/quantdesk/render.py +787 -0
  30. package/dist/quant/engine/quantdesk/roundtrips.py +124 -0
  31. package/dist/quant/engine/quantdesk/score.py +817 -0
  32. package/dist/quant/engine/quantdesk/senpi_history.py +160 -0
  33. package/dist/quant/engine/quantdesk/smart_money.py +354 -0
  34. package/dist/quant/engine/quantdesk/strategy_read.py +292 -0
  35. package/dist/quant/engine/quantdesk/taxonomy.py +55 -0
  36. package/dist/quant/engine/quantdesk/timing.py +164 -0
  37. package/dist/quant/engine/quantdesk/voice.py +42 -0
  38. package/dist/quant/engine-runner.d.ts +133 -0
  39. package/dist/quant/engine-runner.d.ts.map +1 -0
  40. package/dist/quant/engine-runner.js +306 -0
  41. package/dist/quant/engine-runner.js.map +1 -0
  42. package/dist/quant/job.d.ts +125 -0
  43. package/dist/quant/job.d.ts.map +1 -0
  44. package/dist/quant/job.js +184 -0
  45. package/dist/quant/job.js.map +1 -0
  46. package/dist/quant/register.d.ts +53 -0
  47. package/dist/quant/register.d.ts.map +1 -0
  48. package/dist/quant/register.js +253 -0
  49. package/dist/quant/register.js.map +1 -0
  50. package/dist/quant/registry.d.ts +105 -0
  51. package/dist/quant/registry.d.ts.map +1 -0
  52. package/dist/quant/registry.js +248 -0
  53. package/dist/quant/registry.js.map +1 -0
  54. package/dist/quant/types.d.ts +216 -0
  55. package/dist/quant/types.d.ts.map +1 -0
  56. package/dist/quant/types.js +121 -0
  57. package/dist/quant/types.js.map +1 -0
  58. package/dist/skills-manager/manifest.js +1 -1
  59. package/dist/skills-manager/skills-manifest.json +1 -1
  60. package/dist/widgets/index.d.ts +8 -1
  61. package/dist/widgets/index.d.ts.map +1 -1
  62. package/dist/widgets/index.js +1 -0
  63. package/dist/widgets/index.js.map +1 -1
  64. package/dist/widgets/registry.d.ts +8 -0
  65. package/dist/widgets/registry.d.ts.map +1 -1
  66. package/dist/widgets/registry.js +26 -1
  67. package/dist/widgets/registry.js.map +1 -1
  68. package/dist/widgets/tool.d.ts +14 -4
  69. package/dist/widgets/tool.d.ts.map +1 -1
  70. package/dist/widgets/tool.js +36 -23
  71. package/dist/widgets/tool.js.map +1 -1
  72. package/dist/widgets/turn-cap.d.ts +33 -0
  73. package/dist/widgets/turn-cap.d.ts.map +1 -0
  74. package/dist/widgets/turn-cap.js +119 -0
  75. package/dist/widgets/turn-cap.js.map +1 -0
  76. package/dist/widgets/types.d.ts +33 -5
  77. package/dist/widgets/types.d.ts.map +1 -1
  78. package/dist/widgets/types.js +4 -3
  79. package/dist/widgets/types.js.map +1 -1
  80. package/dist/widgets/widgets/fund-user-wallet.d.ts.map +1 -1
  81. package/dist/widgets/widgets/fund-user-wallet.js +1 -0
  82. package/dist/widgets/widgets/fund-user-wallet.js.map +1 -1
  83. package/dist/widgets/widgets/quant-desk-recommendations.d.ts +52 -0
  84. package/dist/widgets/widgets/quant-desk-recommendations.d.ts.map +1 -0
  85. package/dist/widgets/widgets/quant-desk-recommendations.js +278 -0
  86. package/dist/widgets/widgets/quant-desk-recommendations.js.map +1 -0
  87. package/openclaw.plugin.json +12 -2
  88. 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]]