@senpi/runtime 3.0.148 → 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.
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,878 @@
1
+ #!/usr/bin/env python3
2
+ """quant-desk — paste any Hyperliquid address, get the desk.
3
+
4
+ python3 desk.py 0x<address> # the full desk as Markdown
5
+ python3 desk.py 0x<address> --section protection # one section, from the cached run if fresh
6
+ python3 desk.py 0x<address> --json # the analysis document (JSON on stdout)
7
+ python3 desk.py 0x<address> --fixture F --dry # offline, from recorded responses
8
+
9
+ Reads 90 days of fills, funding, fees, the equity curve, the live book and its resting orders from
10
+ Hyperliquid's public Info API (no auth), hourly candles for every coin touched, the public leaderboard for
11
+ the weekly rank, and — when a Senpi token is present — the smart-money cohort from Senpi discovery
12
+ (public-leaderboard cohort otherwise). Fails open: every optional layer degrades to a warning in `meta`.
13
+ """
14
+ # Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
15
+ import argparse
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+ import tempfile
22
+ import time
23
+
24
+ HERE = os.path.dirname(os.path.abspath(__file__))
25
+ sys.path.insert(0, HERE)
26
+
27
+ import deep as deep_mod # noqa: E402
28
+ import followups # noqa: E402
29
+ import hl_api # noqa: E402
30
+ import market as market_mod # noqa: E402
31
+ import metrics # noqa: E402
32
+ import opportunities # noqa: E402
33
+ import render # noqa: E402
34
+ import score # noqa: E402
35
+ import dsl as dsl_mod # noqa: E402
36
+ import book as book_mod # noqa: E402
37
+ import senpi_history # noqa: E402
38
+ import smart_money # noqa: E402
39
+ import strategy_read # noqa: E402
40
+ import taxonomy # noqa: E402
41
+ import timing as timing_mod # noqa: E402
42
+ from roundtrips import episodes_from_fills # noqa: E402
43
+
44
+ ADDR_RE = re.compile(r"^0x[0-9a-fA-F]{40}$")
45
+ # The benchmark medians ship next to the engine when someone recomputes them (benchmark.py --out);
46
+ # absent, the desk runs without the benchmark table, as it always has.
47
+ BENCH_PATH = os.path.join(HERE, "benchmark.json")
48
+ # desk.py's version is the runtime package's (see _version.py). Printed by --version and in the
49
+ # header, so a stale engine is still one command away.
50
+ from _version import VERSION # noqa: E402
51
+
52
+ # The document's shape version. Readers in the runtime accept this version only; a change to the
53
+ # document's shape bumps it, and older stored runs are regenerated rather than guessed at.
54
+ DOC_VERSION = 1
55
+
56
+ DEFAULT_STATE_DIR = os.path.join(tempfile.gettempdir(), "quant-desk")
57
+ FRESH_S = 600
58
+ PUBLIC_COHORT_N = 80 # live books read for the public smart-money cohort (parallel, cached 2 min)
59
+
60
+
61
+ def log(msg):
62
+ print(msg, file=sys.stderr, flush=True)
63
+
64
+
65
+ # OpenClaw renders a RUNNING exec's stderr to the chat as it is produced, so these lines are the
66
+ # user's only company during a 40s sweep. They were nine gerund clauses with no number and no
67
+ # position — "scanning …, auditing …, running …" — which a host concatenates into one run-on
68
+ # sentence, and which is exactly what a reader saw. A step marker, the elapsed clock and a number
69
+ # the run has just learned make each line a beat that has to stand on its own.
70
+ _STEPS = 8
71
+
72
+
73
+ def step(n, msg, t0=None, found=None):
74
+ where = f"[{n}/{_STEPS}]"
75
+ when = f" {time.time() - t0:.0f}s" if t0 else ""
76
+ what = f" — {found}" if found else ""
77
+ print(f"[quant-desk] {where}{when} {msg}{what}", file=sys.stderr, flush=True)
78
+
79
+
80
+ def _mcp_client(meta):
81
+ if not os.environ.get("SENPI_AUTH_TOKEN"):
82
+ return None
83
+ try:
84
+ from mcp_client import MCPClient
85
+ return MCPClient()
86
+ except Exception as e: # noqa: BLE001
87
+ meta.setdefault("warnings", []).append(f"senpi client unavailable: {e}")
88
+ return None
89
+
90
+
91
+ class _MCPFixture:
92
+ def __init__(self, recorded):
93
+ self._r = recorded
94
+
95
+ def mcp_call(self, tool, timeout=12, **kw):
96
+ if "time_frame" in kw and "offset" in kw:
97
+ k = f"{tool}::{kw['time_frame']}::{kw['offset']}"
98
+ if k in self._r:
99
+ return self._r[k]
100
+ addrs = kw.get("trader_addresses") or kw.get("addresses") or ([kw["trader_address"]] if kw.get("trader_address") else None)
101
+ if addrs:
102
+ k = f"{tool}::{str(addrs[0]).lower()}"
103
+ if k in self._r:
104
+ return self._r[k]
105
+ if "offset" in kw:
106
+ k = f"{tool}::{kw['offset']}"
107
+ if k in self._r:
108
+ return self._r[k]
109
+ if tool in self._r:
110
+ return self._r[tool]
111
+ raise RuntimeError(f"fixture has no {tool}")
112
+
113
+
114
+ # Hourly candles over 91 days, one request per coin, six at a time. It is the single most expensive
115
+ # step in the desk and it scales with how many names a book touches — which is unbounded. A wallet
116
+ # trading 174 coins asked for 187 candle pulls and the run was SIGTERM'd by the agent's exec timeout
117
+ # at "reading the tape: 120 of 187", having done all the work and produced nothing. Breadth is not a
118
+ # defect (a systematic book legitimately runs 80 names), so this caps the READ rather than refusing
119
+ # the book, and says in `meta` what it covered. (a live desk, 2026-09-23.)
120
+ MAX_TAPE_COINS = 60
121
+
122
+ # A market maker's desk is wrong in every line and expensive to produce. `userRole` catches the ones
123
+ # that are VAULTS; it returns `user` for a market maker quoting from a plain address, and those exist
124
+ # on the leaderboard today (`0x956a…`, 99% resting at 0.34 bp across 24 coins, role `user`).
125
+ #
126
+ # What separates them is not how much they rest. Measured over 7 days on 13 real wallets: HLP
127
+ # Strategy B — the one that actually burned a user session — is 41% maker, BELOW several ordinary
128
+ # traders, while a perfectly normal whale is 91% maker. Maker share would have missed the real one
129
+ # and refused a real reader.
130
+ #
131
+ # The fee rate was tried as that line and does NOT hold. (@im-vignesh, #763.) Hyperliquid PUBLISHES
132
+ # the schedule that produces a low rate: `userFees.feeSchedule.tiers.vip` sets the maker fee to
133
+ # 0.0 above $500M of 14-day volume, so effective = taker_share x 2.8bp and any patient limit trader
134
+ # at scale crosses 0.5 bp at ~18% taker share, on fees anyone can get. Re-sampled over the top 30 of
135
+ # the leaderboard by weekly volume, 25 wallets with >=200 perp fills:
136
+ # -0.30 -0.25 -0.21 -0.10 -0.04 -0.02 0.05 0.11 0.15 0.21 0.24
137
+ # 0.42 0.50 0.57 0.78 0.79 1.04 1.23 1.24 1.36 1.54 1.89 2.37 2.47 2.82
138
+ # Nine sit inside the "structural gap" the earlier comment claimed; the distribution is continuous
139
+ # and 13 of 25 would have been refused, including VIP traders at 0.42 and 0.50 bp. The gap was an
140
+ # artefact of a 13-wallet sample.
141
+ #
142
+ # So the gate is no longer a CLASSIFIER of who someone is — a claim that can be false and insulting
143
+ # when it is. It is a statement about what this tool can do: the desk pulls hourly candles per coin
144
+ # and caps that read at MAX_TAPE_COINS. Past the cap it is scoring a SAMPLE of the book while
145
+ # printing a verdict about the book. That is the actual harm, it is measured rather than inferred,
146
+ # and it is true of a systematic trader on 200 names exactly as it is of a quoting engine — both
147
+ # deserve the same honest answer instead of an accusation.
148
+ #
149
+ # Measured on the same 29 wallets: the breadth line refuses 1 (115 coins, 5,514 fills/h) where the
150
+ # fee line refused 13. It still refuses the book that prompted this work (172 coins, 177 positions,
151
+ # 13,722 fills), and it serves every VIP trader the fee line wrongly turned away.
152
+ MM_MIN_FILLS = 200 # below this the fee rate is noise, not a schedule
153
+
154
+
155
+ def market_maker_rate(fills):
156
+ """Effective fee in basis points across every fill, or None when there is too little to judge.
157
+
158
+ NOT abs(): a maker rebate is negative fees, money earned, and the most market-maker-ish signal
159
+ there is — taking the absolute value would hide the clearest case.
160
+ """
161
+ perp = [f for f in fills if metrics.is_perp(f.get("coin", ""))]
162
+ if len(perp) < MM_MIN_FILLS:
163
+ return None
164
+ vol = sum(float(f["sz"]) * float(f["px"]) for f in perp)
165
+ if vol <= 0:
166
+ return None
167
+ return sum(float(f["fee"]) for f in perp) / vol * 1e4
168
+
169
+
170
+ class NotATraderError(Exception):
171
+ """Raised before the expensive work when the subject is not a trader's book."""
172
+
173
+ def __init__(self, payload):
174
+ super().__init__(payload.get("error") or "not a trader")
175
+ self.payload = payload
176
+
177
+
178
+ def _tape(coins, book, track, meta):
179
+ """The coins to pull hourly candles for, in priority order, capped at MAX_TAPE_COINS.
180
+
181
+ Priority is where the reader's money is, not alphabetical: every OPEN position first (the
182
+ stop ladder and the regime read are about those), then BTC (every beta and correlation figure is
183
+ against it), then traded coins by volume. Cohort and attention coins fill whatever is left — they
184
+ colour the read, they are not the read.
185
+
186
+ The cap binds on held coins only when the held set alone outruns it. That happens — the book
187
+ this was written for had 177 open positions — and when it does `meta["tape"]` says so rather
188
+ than reporting "every open position" over a silent truncation.
189
+ """
190
+ held = {p["coin"] for p in book["positions"]}
191
+ # metrics.py emits `volume_share` per coin — `volume` lives only on the internal accumulator
192
+ # and was never in this dict, so every key read 0. The order looked right only because metrics
193
+ # already sorts by volume and Python's sort is stable: a real no-op wearing a correct result.
194
+ by_vol = sorted((track.get("coins") or {}).items(), key=lambda kv: -(kv[1].get("volume_share") or 0))
195
+ order, seen = [], set()
196
+ for c in list(held) + ["BTC"] + [c for c, _ in by_vol] + sorted(coins):
197
+ if c in coins and c not in seen:
198
+ seen.add(c); order.append(c)
199
+ if len(order) <= MAX_TAPE_COINS:
200
+ return set(order)
201
+ kept, dropped = order[:MAX_TAPE_COINS], order[MAX_TAPE_COINS:]
202
+ if "BTC" in order and "BTC" not in kept:
203
+ # Held coins lead the order, so a book with more open positions than the cap pushed BTC out
204
+ # entirely — and every beta and correlation figure in the desk is measured against it. One
205
+ # reserved slot is cheaper than a beta that is quietly wrong.
206
+ displaced = kept[-1]
207
+ kept[-1] = "BTC"
208
+ dropped = [c for c in dropped if c != "BTC"] + [displaced]
209
+ # Held coins lead `order`, so they are dropped only when the held set ALONE outruns the cap —
210
+ # the 177-position book this PR was written for. Lifting the cap to cover it would mean ~178
211
+ # candle requests, which is the timeout this function exists to prevent. The tape drives the
212
+ # regime/timing read, not the protection audit (that reads resting orders), so a dropped held
213
+ # coin loses its regime colour and keeps its stop check. What must not happen is claiming
214
+ # otherwise: state the shortfall instead of printing "every open position" over a truncation.
215
+ held_dropped = [c for c in dropped if c in held]
216
+ if held_dropped:
217
+ meta["tape"] = {"coins_touched": len(order), "coins_read": len(kept), "dropped": len(dropped),
218
+ "held_dropped": len(held_dropped),
219
+ "rule": f"the {MAX_TAPE_COINS} largest of {len(held)} open positions, by volume — "
220
+ f"{len(held_dropped)} held coins are past the cap and have no tape"}
221
+ meta.setdefault("warnings", []).append(
222
+ f"wide book: {len(held)} open positions exceed the {MAX_TAPE_COINS}-coin tape cap, so "
223
+ f"{len(held_dropped)} of them are read without candles — their stops are still audited, "
224
+ f"their regime is not")
225
+ else:
226
+ meta["tape"] = {"coins_touched": len(order), "coins_read": len(kept), "dropped": len(dropped),
227
+ "held_dropped": 0,
228
+ "rule": f"every open position and BTC, then the largest by volume up to {MAX_TAPE_COINS}"}
229
+ meta.setdefault("warnings", []).append(
230
+ f"wide book: {len(order)} coins touched, hourly tape read for the {len(kept)} that carry the "
231
+ f"position risk and the volume")
232
+ return set(kept)
233
+
234
+
235
+ def _ratio_or_none(num, base, cap=10.0):
236
+ """None when the base is too small for the ratio to mean anything (|ratio| > cap)."""
237
+ if not base:
238
+ return None
239
+ r = num / base
240
+ return None if abs(r) > cap else r
241
+
242
+
243
+ def analyze(addr, hl, days=90, mcp=None, want_rank=True, want_cohort=True, bench=None, meta=None, whose="mine",
244
+ wallets=None, force=False):
245
+ """One desk. `wallets`, when given, is every wallet of a senpi user's book: each is read on its
246
+ own and the reads are unioned into a single `tr_raw` (see book.py). `addr` stays the label."""
247
+ meta = meta if meta is not None else {}
248
+ meta.setdefault("warnings", []); meta["timings"] = {}; meta["sources"] = {}
249
+ t0 = time.time()
250
+ pre_closed = pre_opened = None
251
+ if wallets:
252
+ step(1, f"scanning every fill across {len(wallets)} wallets …")
253
+ tr_raw, pre_closed, pre_opened, per_wallet = book_mod.read(hl, wallets, days=days, progress=log)
254
+ meta["wallets"] = per_wallet
255
+ else:
256
+ step(1, "scanning every fill, funding payment, transfer and resting order …")
257
+ tr_raw = hl.trader(addr, days=days)
258
+ meta["timings"]["trader"] = round(time.time() - t0, 1)
259
+ fills, cs, oo = tr_raw["fills"], tr_raw["clearinghouseState"], tr_raw["frontendOpenOrders"]
260
+ win_start, now = tr_raw["window_start_ms"], tr_raw["now_ms"]
261
+ # Stop here, not later. Everything past this point is the expensive half — hourly candles for
262
+ # every coin touched and two 100-wallet cohort reads, ~60-90s of a ~120s run. Bailing now costs
263
+ # the reader ~30s instead of an exec timeout, and costs us one trader read instead of a sweep.
264
+ _bp = market_maker_rate(fills)
265
+ _coins = sorted({f["coin"] for f in fills if metrics.is_perp(f.get("coin", ""))})
266
+ if len(_coins) > MAX_TAPE_COINS and not force:
267
+ _read_pct = MAX_TAPE_COINS / len(_coins)
268
+ raise NotATraderError({
269
+ "not_a_trader": "book_wider_than_the_desk_reads",
270
+ "coins": len(_coins), "tape_cap": MAX_TAPE_COINS,
271
+ "readable_share": round(_read_pct, 3),
272
+ "effective_fee_bp": None if _bp is None else round(_bp, 3),
273
+ "fills_read": len(fills), "address": addr,
274
+ "error": f"this book touches {len(_coins)} coins and the desk reads the tape for at most "
275
+ f"{MAX_TAPE_COINS}. Every score past that point describes {_read_pct:.0%} of the "
276
+ f"book while claiming to describe the book.",
277
+ "say_to_the_reader": (
278
+ f"That book is across **{len(_coins)} coins**. I read the tape for {MAX_TAPE_COINS} "
279
+ f"at a time, so anything I scored would cover about {_read_pct:.0%} of it and still "
280
+ f"read like a verdict on the whole thing — I would rather say that than hand you a "
281
+ f"number I cannot stand behind. If there are particular names you care about, give "
282
+ f"me those and I will read them properly."),
283
+ "if_you_meant_it": "re-run with --force to score the readable slice anyway"})
284
+ if _bp is not None and _bp < 0:
285
+ meta.setdefault("warnings", []).append(
286
+ f"this book EARNS {abs(_bp):.2f} bp on its fills rather than paying — a rebate the "
287
+ f"published schedule does not offer (it floors the maker fee at 0.0). Read the edge "
288
+ f"figures below as a quoting book's, not a directional trader's")
289
+ ctxs = hl.meta()
290
+ ctx_xyz = None
291
+ try:
292
+ ctx_xyz = hl.meta("xyz")
293
+ except Exception as e: # noqa: BLE001
294
+ meta["warnings"].append(f"xyz contexts unavailable: {e}")
295
+ # Episodes for a book are built PER WALLET upstream: `episodes_from_fills` follows position per
296
+ # coin through `startPosition`, and two wallets both trading BTC interleave into one broken track.
297
+ pub_closed, pub_opened = (pre_closed, pre_opened) if pre_closed is not None else episodes_from_fills(fills)
298
+ closed, opened = pub_closed, pub_opened
299
+ ages = {}
300
+ if mcp is not None:
301
+ try:
302
+ resp = mcp.mcp_call("discovery_get_trader_state", trader_addresses=[addr], include_position_age=True, timeout=20)
303
+ for t in smart_money._traders_of(smart_money._ok(resp)):
304
+ for p in (t.get("openPositions") or t.get("open_positions") or []):
305
+ st = smart_money._f(p, "startTime", "start_time", default=0.0)
306
+ if isinstance(p, dict) and p.get("coin") and st:
307
+ ages[p["coin"]] = st * 1000.0 if st < 1e12 else st
308
+ except Exception as e: # noqa: BLE001
309
+ meta["warnings"].append(f"own position ages unavailable: {e}")
310
+ source = "public fills"
311
+ cov = metrics.coverage(closed, opened, fills, tr_raw["userFees"])
312
+ indexed = None
313
+ if mcp is not None:
314
+ public_closed = len(closed)
315
+ t_h = time.time()
316
+ rows = []
317
+ for _w in (wallets or [addr]):
318
+ _r = senpi_history.fetch(mcp, _w, win_start, meta)
319
+ for _e in _r:
320
+ _e["wallet"] = _w
321
+ rows.extend(_r)
322
+ rows.sort(key=lambda e: e.get("close_time") or e.get("open_time") or 0)
323
+ meta["timings"]["senpi_history"] = round(time.time() - t_h, 1)
324
+ if rows:
325
+ _partial = " — PARTIAL, a page failed to read and the totals below are short" if meta.get("senpi_history_partial") else ""
326
+ _n = f"{len(rows)} closed position{'s' if len(rows) != 1 else ''}"
327
+ if wallets:
328
+ # PER WALLET, not per book. `closed = rows` replaced the whole book's public
329
+ # episodes with whatever senpi returned, so a wallet senpi has no rows for
330
+ # contributed ZERO and one indexed wallet erased the others. Measured: B held 48
331
+ # public closed trades and no senpi rows; the book reported 3 trades and
332
+ # by_wallet B = 0, sourced "senpi discovery (3 closed positions)", indexed True.
333
+ # A failed read on B did the same — senpi_history_partial is set only when a LATER
334
+ # page fails, so the only trace was a footnote. (@shnoodles, #755/#773.)
335
+ _idx = {e.get("wallet") for e in rows if e.get("wallet")}
336
+ _fb = [w for w in wallets if w not in _idx]
337
+ _pub = [e for e in closed if e.get("wallet") in set(_fb)] if _fb else []
338
+ closed = sorted(rows + _pub,
339
+ key=lambda e: e.get("close_time") or e.get("open_time") or 0)
340
+ meta["indexed_wallets"] = sorted(_idx)
341
+ meta["public_fallback_wallets"] = _fb
342
+ source = (f"senpi discovery ({_n}){_partial}" + (
343
+ f" + public fills for {len(_fb)} wallet{'s' if len(_fb) != 1 else ''} senpi has "
344
+ f"not indexed ({', '.join(w[:6] + '…' + w[-4:] for w in _fb)})" if _fb else ""))
345
+ else:
346
+ closed, source = rows, f"senpi discovery ({_n}){_partial}"
347
+ indexed = True
348
+ elif public_closed and not meta.get("senpi_history_failed"):
349
+ # The public endpoints show closed round trips in this window and senpi's index returned
350
+ # none for the same window. That is a CONTRADICTION between two sources, not a quiet
351
+ # wallet: this address is not in the index yet. Without the distinction the desk drops
352
+ # silently to public fills — which miss TWAP slices — and a whale gets a confident desk
353
+ # built on a fraction of their volume, with nothing in the output saying so.
354
+ indexed = False
355
+ # A read senpi could not ANSWER is not a wallet senpi does not HAVE. Leaving `indexed` at None
356
+ # on a failed read is the difference between "we don't know" and a confident wrong claim.
357
+ meta["sources"]["trades"] = source
358
+ meta["indexed"] = indexed
359
+ track = metrics.track_record(closed, opened, tr_raw["userFunding"], tr_raw["userFees"], win_start, tr_raw.get("userFees_xyz"))
360
+ track["coverage"] = cov
361
+ track["ledger_net"] = metrics.ledger_pnl(tr_raw["portfolio"], win_start)
362
+ track["fill_taker_share"] = track["taker_share"]
363
+ if source != "public fills":
364
+ # senpi rows carry no maker/taker split — keep the fill-level execution read from the public stream
365
+ fb_closed, fb_open = pub_closed, pub_opened # already built above, over the same fills
366
+ fb = metrics.track_record(fb_closed, fb_open, tr_raw["userFunding"], tr_raw["userFees"], win_start, tr_raw.get("userFees_xyz"))
367
+ track["taker_share"], track["fee_recoverable"], track["volume"] = fb["taker_share"], fb["fee_recoverable"], fb["volume"]
368
+ # …and the RATES with them. Item 12 measures them from the fills, and discovery episodes carry
369
+ # no `taker_fees`, so on the indexed path `_tk_fees` sums to 0 and both rates fall back to the
370
+ # schedule — leaving schedule-derived basis points sitting beside fills-derived volume, in a
371
+ # sentence that still does not multiply out. (@0xsarvesh, #718, on 1.24.0.)
372
+ track["fee_rate_taker"], track["fee_rate_maker"] = fb["fee_rate_taker"], fb["fee_rate_maker"]
373
+ # …and the fee TOTAL those rates and that volume belong to. `track["fees"]` stays discovery's,
374
+ # because the P&L breakdown above it is closed-trade accounting and that is the right number
375
+ # there — but the execution sentence quotes fills-derived volume and fills-derived rates, and
376
+ # was pairing them with discovery's fee total: "$218,602 in fees on $1.39B of volume at 2.8 bp
377
+ # taker" multiplies out to $339k, off by 1.55x. Found on the first live 1.24.1 run.
378
+ track["fee_total_exec"] = fb["fees"]
379
+ step(2, "auditing the live book — every position's stop, liquidation distance and funding …", t0,
380
+ f"{len(fills):,} fills across {len({e.get('coin') for e in fills})} coins")
381
+ book = metrics.open_book(cs, oo, ctxs, ages, tr_raw.get("clearinghouseState_xyz"), tr_raw.get("frontendOpenOrders_xyz"), ctx_xyz,
382
+ metrics.whole_account_value(tr_raw.get("portfolio"), tr_raw.get("spotClearinghouseState")),
383
+ metrics.spot_free_usdc(tr_raw.get("spotClearinghouseState")))
384
+ # B5 (@0xsarvesh, #718). The startPosition-jump heuristic can only see gaps it can infer from the
385
+ # fills it DID get — a whole TWAP series older than the retained window leaves no jump behind.
386
+ # Hyperliquid's own P&L series is an independent witness: what we rebuilt from fills, plus what
387
+ # the open book is carrying, should land on the ledger's own delta. It never lands exactly (a
388
+ # position already open when the window opened carries unrealized P&L that predates it), so this
389
+ # is not a gate — it LOWERS the coverage figure the desk already prints when the gap is bigger
390
+ # than the open book can explain, and stays out of the reader's page otherwise.
391
+ cov["reconstructed_net"] = track["net"] + book["unrealized"]
392
+ cov["ledger_net"] = track["ledger_net"]
393
+ if track["ledger_net"]:
394
+ _gap = abs(track["ledger_net"] - cov["reconstructed_net"])
395
+ cov["ledger_gap"] = _gap
396
+ cov["effective"] = min(cov["overall"] if cov["overall"] is not None else 1.0,
397
+ max(0.0, 1.0 - _gap / abs(track["ledger_net"])))
398
+ # What senpi's own runtime is doing to these positions. The desk already reads the resting stop
399
+ # off the exchange, so a DSL position is correctly PROTECTED today — but a price with no context
400
+ # reads as a static stop when it is a floor that ratchets. Only ever annotates a position whose
401
+ # backend row the exchange corroborates (see dsl.corroborated). Silent no-op without a token, on
402
+ # an external wallet, or on any failure.
403
+ _t_dsl = time.time()
404
+ # Read once, used twice: the DSL annotation below, and the Set stop loss gate at the end of the run
405
+ # (a senpi strategy wallet is not one the reader can sign for). Never raises — empty on failure.
406
+ _strats = [] if wallets else dsl_mod.strategies_for(mcp, addr)
407
+ try:
408
+ _n_dsl = dsl_mod.attach(mcp, addr, book, meta, strats=None if wallets else _strats)
409
+ if _n_dsl:
410
+ # was measured from `t1`, an earlier mark, so this read as the cumulative time to here
411
+ # rather than what the DSL calls cost. (@0xsarvesh, #753.)
412
+ meta["timings"]["dsl"] = round(time.time() - _t_dsl, 1)
413
+ except Exception as e: # noqa: BLE001
414
+ meta["warnings"].append(f"runtime DSL state unavailable: {e}")
415
+ fl = metrics.flows(tr_raw["ledger"], addr)
416
+ fl = metrics.flows(tr_raw["ledger"], wallets or addr)
417
+ pnl_curve = metrics.pnl_series(tr_raw["portfolio"], win_start)
418
+ # transfer-adjusted, as equity_curve's docstring, methodology.md and SKILL rule 3 all promise.
419
+ # `fl` is computed on the line above; passing [] meant a trader who withdrew their profit read as
420
+ # a blown account — identical trades, +$55k, scored dd 90%/risk 18 withdrawn vs dd 4%/risk 82 left
421
+ # on the exchange.
422
+ eq = metrics.equity_curve(tr_raw["portfolio"], fl, win_start)
423
+ # drawdown needs the RAW curve, not this one. Its numerator (cumulative P&L) is already
424
+ # transfer-immune; its denominator is "the equity the fall came out of", and feeding it the
425
+ # transfer-ADJUSTED curve made `av_at` go negative on an account funded mid-window — base
426
+ # collapsed to ~0, dd_pct read 0%, and a real drawdown lost its risk penalty. The mirror case
427
+ # saturated to 100% and printed "the account went to zero" on a live funded book. My B3 fix in
428
+ # #733 introduced this. (@danielmbirochi, #718.)
429
+ eq_raw = metrics.equity_curve(tr_raw["portfolio"], [], win_start)
430
+ pnl_pts = pnl_curve # same call, same args — computed once
431
+ dd = metrics.drawdown(pnl_pts, eq_raw)
432
+ funded = [v for _, v in eq if v > 0]
433
+ avg_eq = (sum(funded) / len(funded)) if funded else None
434
+ equity = dict(points=len(eq), start=eq[0][1] if eq else None, end=eq[-1][1] if eq else None, avg=avg_eq,
435
+ # a return is only a return against an equity base that means something. On a book
436
+ # that decayed to $0 the average equity is a rounding error and this read -3191.5%,
437
+ # which tells a reader nothing except that the denominator collapsed.
438
+ return_on_avg_equity=_ratio_or_none((track["ledger_net"] if track.get("ledger_net") is not None else track["net"]), avg_eq),
439
+ net_flows=sum(a for _, a in fl))
440
+ act = metrics.activity(fills, win_start)
441
+ majors, large = taxonomy.crypto_tiers(ctxs)
442
+ breadth = market_mod.breadth(ctxs, ctx_xyz)
443
+ # ---- cohorts + attention first (they name coins the candle pull must cover)
444
+ rank, labels, lb = None, None, None
445
+ cohorts, attention, fregime = [], None, None
446
+ if (want_rank and not wallets) or (want_cohort and mcp is None):
447
+ t2 = time.time()
448
+ try:
449
+ lb = hl.leaderboard()
450
+ except Exception as e: # noqa: BLE001
451
+ meta["warnings"].append(f"leaderboard unavailable: {e}")
452
+ meta["timings"]["leaderboard"] = round(time.time() - t2, 1)
453
+ if want_rank and lb and not wallets:
454
+ rank = hl_api.weekly_rank(lb, addr)
455
+ if rank is None:
456
+ meta["warnings"].append("address is not on Hyperliquid's leaderboard this week (no rank)")
457
+ if want_cohort:
458
+ t3 = time.time()
459
+ step(3, "running senpi-smart-money — the proven cohort and the hot 30-day cohort against this book …", t0,
460
+ f"{len(book['positions'])} open position(s), {len(book['naked'])} unprotected")
461
+ if mcp is not None:
462
+ for name, fetch in (("proven", smart_money.proven_cohort), ("hot", smart_money.hot_cohort)):
463
+ try:
464
+ addrs = fetch(mcp, meta)
465
+ bks = smart_money.books(mcp, addrs, meta, progress=log, label={"proven": "the proven cohort, ", "hot": "the hot 30-day cohort, "}.get(name, "")) if addrs else []
466
+ if bks:
467
+ cv = smart_money.cohort_view(name, bks, book, opened, majors, large, ages, now)
468
+ cv["source"] = ("senpi discovery — top traders by all-time realized P&L, ≥ $1M realized" if name == "proven"
469
+ else "senpi discovery — the most profitable traders of the last 30 days, holding positions now")
470
+ cohorts.append(cv)
471
+ except Exception as e: # noqa: BLE001
472
+ meta["warnings"].append(f"{name} cohort failed: {e}")
473
+ if not cohorts and lb:
474
+ addrs = hl_api.public_cohort(lb, n=PUBLIC_COHORT_N)
475
+ states = hl.states(addrs)
476
+ bks = smart_money.public_books(states)
477
+ if bks:
478
+ cv = smart_money.cohort_view("proven", bks, book, opened, majors, large, ages, now)
479
+ cv["source"] = f"Hyperliquid leaderboard — {len(bks)} large profitable accounts with a live book"
480
+ cohorts.append(cv)
481
+ meta["timings"]["cohort"] = round(time.time() - t3, 1)
482
+ meta["sources"]["cohort"] = [c["source"] for c in cohorts]
483
+ if mcp is not None:
484
+ step(4, "running senpi-market-pulse — funding regime, where the top traders' gains sit, momentum …", t0)
485
+ try:
486
+ fregime = market_mod.funding_regime(mcp.mcp_call("market_get_funding_regime", timeout=10))
487
+ except Exception as e: # noqa: BLE001
488
+ meta["warnings"].append(f"funding regime unavailable: {e}")
489
+ try:
490
+ mk = mcp.mcp_call("leaderboard_get_markets", limit=100, timeout=12)
491
+ try:
492
+ mo = mcp.mcp_call("leaderboard_get_momentum_events", limit=50, timeout=12)
493
+ except Exception as e: # noqa: BLE001
494
+ mo = None; meta["warnings"].append(f"momentum events unavailable: {e}")
495
+ attention = market_mod.attention(mk, mo, book)
496
+ except Exception as e: # noqa: BLE001
497
+ meta["warnings"].append(f"top-trader markets unavailable: {e}")
498
+ try:
499
+ resp = mcp.mcp_call("discovery_get_top_traders", time_frame="ALL_TIME", addresses=[addr], limit=1, timeout=15)
500
+ rows = smart_money._traders_of(smart_money._ok(resp))
501
+ if rows:
502
+ t = rows[0]
503
+ labels = dict(consistency=smart_money._field(t, "tcsLabel", "consistency", "consistencyLabel"), risk=smart_money._field(t, "risk", "riskLabel"),
504
+ activity=smart_money._field(t, "activity", "activityLabel"), tcs=smart_money._field(t, "tcsValue", "tcs_score", "tcsScore"),
505
+ roi_all_time=smart_money._field(t, "returnOnInvestment", "roi"), pnl_all_time=smart_money._field(t, "profitAndLoss", "pnl"),
506
+ win_rate=smart_money._field(t, "winRate", "win_rate"), max_drawdown=smart_money._field(t, "maxDrawdown", "max_drawdown"))
507
+ except Exception as e: # noqa: BLE001
508
+ meta["warnings"].append(f"senpi labels unavailable: {e}")
509
+ # ---- candles: every coin the trader touched or holds, BTC, and what the cohorts and top traders are in
510
+ t1 = time.time()
511
+ step(5, "reading the tape — 90 days of candles for every coin touched, regime by regime …", t0)
512
+ # `closed` is discovery's rows on the indexed path, but the lever grid reads the FILLS-derived
513
+ # episodes (B6's discovery half below) — so the tape has to cover both, or the grid silently
514
+ # finds no candles for the coins it was just pointed at and abstains on the whole book.
515
+ coins = {e["coin"] for e in closed + opened + pub_closed + pub_opened if metrics.in_window(e, win_start)} \
516
+ | {p["coin"] for p in book["positions"]} | {"BTC"}
517
+ for cv in cohorts:
518
+ coins |= {h["coin"] for h in cv.get("they_hold") or []}
519
+ if attention:
520
+ coins |= {m["coin"] for m in attention["markets"] if m.get("coin")}
521
+ coins = _tape(coins, book, track, meta)
522
+ try:
523
+ candles = timing_mod.load_candles(hl.candles(sorted(coins), days=days + 1))
524
+ except Exception as e: # noqa: BLE001
525
+ candles = {}; meta["warnings"].append(f"candles unavailable: {e}")
526
+ basket = ["BTC", "ETH"] + [c for c in (breadth.get("majors") or []) + (breadth.get("large") or []) if c not in ("BTC", "ETH")][:10]
527
+ try:
528
+ daily = hl.candles(basket, days=days + 2, interval="1d")
529
+ except Exception as e: # noqa: BLE001
530
+ daily = {}; meta["warnings"].append(f"daily candles unavailable: {e}")
531
+ meta["timings"]["candles"] = round(time.time() - t1, 1)
532
+ in_win = [e for e in closed if metrics.in_window(e, win_start)]
533
+ # B6, the discovery half (@0xsarvesh, #718). A discovery row is NOT a position held at one size:
534
+ # the largest xyz:SKHX row on 0x615f9484… is 13,651 fills over 20 days carrying `size` 7,999
535
+ # against a peak concurrent exposure near 880 — `size` accumulates over the position's life. The
536
+ # degenerate one-step path priced an exit counterfactual on $9.9M of notional that never existed
537
+ # at once: the same constant-size bug B6 removed on the fills path, on a LARGER base. Measured on
538
+ # one wallet, same window, same minute: fills $35,032 (0.188 of losses) vs discovery $1,043,709
539
+ # (1.032) — 30x apart, and the indexed number is the one every token-holding user gets.
540
+ #
541
+ # The fills are fetched on every run whatever the source, so the split is: TOTALS from discovery,
542
+ # where it is genuinely more complete, and the LEVER GRID from episodes that carry a real size
543
+ # path. An incomplete real exposure beats a complete fictional one.
544
+ lev_win = in_win
545
+ if source != "public fills":
546
+ lev_win = [e for e in pub_closed if metrics.in_window(e, win_start)]
547
+ meta["sources"]["levers"] = (f"public fills ({len(lev_win)} rebuildable round trips) — the "
548
+ f"counterfactual grid needs the size path discovery rows do not carry")
549
+ tm_rows = timing_mod.per_trade(lev_win, candles)
550
+ tm = timing_mod.summarize(tm_rows) if tm_rows else None
551
+ mf = market_mod.book_fit(book, candles, ctxs)
552
+ regimes_days = market_mod.daily_regimes(daily, basket)
553
+ rperf = market_mod.regime_performance(in_win, regimes_days)
554
+ sm = cohorts[0] if cohorts else None
555
+ ctx_by = {u["name"]: c for u, c in zip(ctxs[0]["universe"], ctxs[1])}
556
+ if ctx_xyz:
557
+ ctx_by.update({u["name"]: c for u, c in zip(ctx_xyz[0]["universe"], ctx_xyz[1])})
558
+ coin_regimes = {c: market_mod.coin_regime(c, candles, ctx_by.get(c)) for c in coins}
559
+ step(6, "finding the leaks, pricing the fixes, running senpi-signals for live matches …", t0,
560
+ f"{len(coins)} coins of tape")
561
+ # funding is a lever too, so it is priced once here and read by both
562
+ _fl = min(score._funding_after(tr_raw["userFunding"], in_win, win_start, 24.0),
563
+ -float(track.get("funding") or 0.0))
564
+ # `closed` runs days+60 so episodes opening before the window can still be completed; every
565
+ # figure the reader sees is 90-day. Handing the raw set to the levers put the size lever's
566
+ # median-winner threshold on up to 150 days inside a 90-day desk. (@danielmbirochi, #718.)
567
+ lv = score.levers(tm_rows, lev_win, tm, funding_late=max(0.0, _fl))
568
+ lk = score.leaks(track, book, tm, tr_raw["userFunding"], in_win, win_start, days, lv)
569
+ # the ONE quotable number: a union over trades, never the sum of the leaks above
570
+ rec = score.recoverable(tm_rows, lev_win, track, tm, lv)
571
+ setups = score.best_setups(lev_win, tm_rows)
572
+ step(7, "reading the playbook — what the book actually does, by class, side and size …", t0,
573
+ f"{len(lk)} leak(s) priced")
574
+ fp = strategy_read.fingerprint(in_win, opened, book, track, act, tm, candles, ctxs, pnl_curve, win_start, now, equity=eq)
575
+ strategy = dict(fingerprint=fp, statements=strategy_read.statements(fp, track, book), critique=strategy_read.critique(fp, track, book, mf, sm, cohorts))
576
+ context = dict(breadth=breadth, funding_regime=fregime, attention=attention, regime_days=regimes_days, regime_performance=rperf)
577
+ opps = opportunities.scout(in_win, setups, book, breadth, coin_regimes, cohorts, attention, majors, large)
578
+ step(8, "scoring the book on six dimensions, comparing to the top traders, scouting today's matches …", t0)
579
+ dims, quant = score.dimensions(track, book, dd, tm, mf, sm, in_win, pnl_curve) # 90-day window, as everything else
580
+ r = dict(address=addr, days=days, now_ms=now, window_start_ms=win_start, activity=act, track=track, book=book, equity=equity, drawdown=dd,
581
+ pnl_curve=pnl_curve[-120:], timing=tm, market=mf, rank=rank, smart=sm, cohorts=cohorts, labels=labels, dimensions=dims, quant_score=quant,
582
+ archetype=score.archetype(track, book, tm, act, opened), flags=score.flags(track, book, dd, tm, mf, labels), leaks=lk, recoverable=rec,
583
+ setups=setups, families=score.families(closed, tm, track), strategy=strategy, context=context, opportunities=opps,
584
+ benchmark=bench, benchmark_table=smart_money.benchmark_table(track, bench) if bench else None,
585
+ episodes=[{k: v for k, v in e.items() if k != "size_path"} for e in in_win][-300:], meta=meta)
586
+ r["whose"] = whose
587
+ r["indexed"] = indexed
588
+ if wallets:
589
+ r["wallets"] = list(wallets)
590
+ # Which wallet did what. A book answers "how am I trading"; this answers "and which of my
591
+ # strategies is carrying it" — the question a reader asks next, and the only one the union
592
+ # destroys by construction.
593
+ _bw = {w: dict(wallet=w, trades=0, wins=0, realized=0.0, fees=0.0, volume=0.0, unrealized=0.0, open=0) for w in wallets}
594
+ for e in in_win:
595
+ row = _bw.get(e.get("wallet"))
596
+ if row is None:
597
+ continue
598
+ row["trades"] += 1; row["wins"] += 1 if e["win"] else 0
599
+ row["realized"] += e["realized"]; row["fees"] += e["fees"]; row["volume"] += e["volume"]
600
+ # track_record charges fees on STILL-OPEN episodes to the window too (the entry was paid for
601
+ # inside it). Leaving them out here made the per-wallet nets sum to $71,221 under a headline
602
+ # net of $67,207 — a table that does not add up to the number above it.
603
+ for e in opened:
604
+ row = _bw.get(e.get("wallet"))
605
+ if row is not None:
606
+ row["fees"] += e["fees"]
607
+ for pos in book["positions"]:
608
+ row = _bw.get(pos.get("wallet"))
609
+ if row is not None:
610
+ row["unrealized"] += pos["unrealized"]; row["open"] += 1
611
+ for x in (tr_raw["userFunding"] or []):
612
+ row = _bw.get(x.get("wallet"))
613
+ if row is not None and x["time"] >= win_start:
614
+ row["funding"] = row.get("funding", 0.0) + float(x["delta"]["usdc"])
615
+ # `realized` on an episode is GROSS. The desk's own headline number is net of fees and
616
+ # funding, and a column headed "Realized" sitting under it had better mean the same thing —
617
+ # the two wallets here summed to +$123,624 gross beside a net of +$67,207 on the same page.
618
+ for row in _bw.values():
619
+ row["funding"] = row.get("funding", 0.0)
620
+ row["net"] = row["realized"] - row["fees"] + row["funding"]
621
+ r["by_wallet"] = sorted(_bw.values(), key=lambda x: -(x["net"] + x["unrealized"]))
622
+ r["verdict"] = score.verdict(track, book, dims, lk)
623
+ r["followups"] = followups.offer(r, whose=whose)
624
+ # The protect card's Set stop loss button carries the ladder's hard stop, so it is priced here, from
625
+ # the candles this run already read: the runtime refuses --deep, and the candles die with the run.
626
+ # The browser places each stop loss with the reader's OWN wallet signature, so one is offered only
627
+ # on a wallet the reader signs for. A --book run is a senpi user's strategy wallets, and a single
628
+ # wallet that is a senpi strategy wallet is senpi's to protect, not the reader's: both would only
629
+ # ever reach the browser's wallet_mismatch refusal, so neither gets a stop loss at all.
630
+ try:
631
+ r["card_stop_losses"] = ([] if wallets or _strats
632
+ else deep_mod.card_stop_losses(book["positions"], candles, addr))
633
+ except Exception as e: # noqa: BLE001
634
+ # a side computation: the desk still ships, the protect card just carries no button
635
+ r["card_stop_losses"] = []
636
+ meta["warnings"].append(f"set-stop-loss prices unavailable: {e}")
637
+ r["cards"] = render.cards(r)
638
+ r["doc_version"] = DOC_VERSION
639
+ meta["timings"]["total"] = round(time.time() - t0, 1); meta["hl_calls"] = hl.calls
640
+ return r
641
+
642
+
643
+ def main(argv=None):
644
+ ap = argparse.ArgumentParser(description="Senpi Quant Desk: the desk for any Hyperliquid address")
645
+ ap.add_argument("address", nargs="?", help="the wallet; omit with --compare")
646
+ g = ap.add_mutually_exclusive_group()
647
+ g.add_argument("--mine", action="store_true", help="the reader's own book (second person)")
648
+ g.add_argument("--other", "--analyst", dest="other", action="store_true", help="someone else's book (analyst mode): third person, learn-from-them follow-ups")
649
+ ap.add_argument("--compare", nargs="+", metavar="0x", help="two or more addresses side by side (cached runs are reused)")
650
+ ap.add_argument("--book", nargs="+", metavar="0x",
651
+ help="ONE desk over several wallets — a senpi user's whole book. Every wallet is read "
652
+ "and the reads are unioned: one score, one set of leaks, one P&L. Use this for a "
653
+ "senpi user (every strategy wallet, closed ones included), not --compare, which "
654
+ "scores each wallet separately and answers a different question.")
655
+ ap.add_argument("--days", type=int, default=90)
656
+ ap.add_argument("--version", action="version", version=f"quant-desk desk.py {VERSION}",
657
+ help="print this script's version — the one gate that catches a stale desk.py")
658
+ ap.add_argument("--json", action="store_true", help="print the analysis document instead of Markdown")
659
+ ap.add_argument("--section", choices=render.SECTIONS, action="append", help="render only these sections (repeatable)")
660
+ ap.add_argument("--deep", choices=sorted(deep_mod.MODES), help="a follow-up deep dive from the cached run (protect, smart, scout, replay, funding, regime, compare, rules, strategy, watch)")
661
+ ap.add_argument("--fixture", help="recorded responses (JSON) — offline run")
662
+ ap.add_argument("--dry", action="store_true", help="never touch the network (requires --fixture)")
663
+ ap.add_argument("--no-rank", action="store_true"); ap.add_argument("--no-cohort", action="store_true")
664
+ ap.add_argument("--cache", default=hl_api.DEFAULT_CACHE, help="HTTP cache dir ('' to disable)")
665
+ ap.add_argument("--state-dir", default=DEFAULT_STATE_DIR)
666
+ ap.add_argument("--force", action="store_true",
667
+ help="read a vault, or a book wider than the desk reads, as if it were a trader's wallet (a refusal that is usually right)")
668
+ ap.add_argument("--fresh", action="store_true", help="ignore a cached analysis")
669
+ ap.add_argument("--find", metavar="BAND", choices=sorted(hl_api.FIND_BANDS),
670
+ help="candidate wallets to run the desk on, by account size: "
671
+ + ", ".join(sorted(hl_api.FIND_BANDS)))
672
+ ap.add_argument("--find-window", default="week", choices=("week", "month", "allTime"),
673
+ help="who is hot right now (week) or who has held up (month/allTime)")
674
+ ap.add_argument("--find-losers", action="store_true",
675
+ help="the worst in the band instead of the best — the desk reads a losing book just as well")
676
+ a = ap.parse_args(argv)
677
+ os.makedirs(a.state_dir, exist_ok=True)
678
+ if a.find:
679
+ hl = hl_api.HL(cache_dir=a.cache or None)
680
+ # A ~40 MB public fetch with no auth and no SLA. Everywhere else the desk degrades on it
681
+ # (`leaderboard unavailable` as a warning); here it was the whole answer and outside any try,
682
+ # so a slow venue printed a traceback for the agent to read back to the reader.
683
+ try:
684
+ lb = hl.leaderboard()
685
+ except Exception as e: # noqa: BLE001
686
+ print(json.dumps({"error": f"Hyperliquid's leaderboard did not answer: {e}",
687
+ "retry": "it is a large public file — worth one more try in a minute"}))
688
+ return 2
689
+ rows = hl_api.find_traders(lb, band=a.find, window=a.find_window,
690
+ losers=a.find_losers)
691
+ print(json.dumps({"band": a.find, "window": a.find_window,
692
+ "worst_first": bool(a.find_losers), "candidates": rows}, indent=2))
693
+ return 0
694
+ if a.compare:
695
+ rs = []
696
+ for x in a.compare:
697
+ x = x.strip().lower()
698
+ if not ADDR_RE.match(x):
699
+ print(json.dumps({"error": f"not a Hyperliquid address: {x}"})); return 2
700
+ sp = os.path.join(a.state_dir, f"desk-{x}.json")
701
+ if os.path.exists(sp) and time.time() - os.path.getmtime(sp) < 6 * FRESH_S and not a.fresh:
702
+ with open(sp) as fh:
703
+ rs.append(json.load(fh)); continue
704
+ sub = [x, "--json", "--state-dir", a.state_dir, "--cache", a.cache, "--other"] + (["--fixture", a.fixture] if a.fixture else []) + (["--dry"] if a.dry else []) + (["--days", str(a.days)] if a.days != 90 else [])
705
+ rc = main(sub if not a.no_cohort else sub + ["--no-cohort"])
706
+ if rc != 0:
707
+ return rc
708
+ with open(sp) as fh:
709
+ rs.append(json.load(fh))
710
+ print(render.render_compare(rs)); return 0
711
+ # A book runs the SAME path as one address — analyze, the empty check, the state file, --deep,
712
+ # --section, the render all work unchanged on a merged read. The only things that differ are
713
+ # which wallets get read, what an empty result means, and that a book is not an address to
714
+ # remember. Everything else is shared, deliberately: a parallel branch here drifts.
715
+ wallets = None
716
+ if a.book:
717
+ wallets = []
718
+ for x in a.book:
719
+ x = x.strip().lower()
720
+ if not ADDR_RE.match(x):
721
+ print(json.dumps({"error": f"not a Hyperliquid address: {x}"})); return 2
722
+ if x not in wallets:
723
+ wallets.append(x)
724
+ # The label is the first wallet: the state file and the header key on it, and a book is not
725
+ # itself an address. `wallets` carries the truth, and the header reads "across N wallets".
726
+ addr = wallets[0]
727
+ elif not a.address:
728
+ print(json.dumps({"error": "an address is required (or --book 0x… 0x… for a whole senpi book, "
729
+ "or --compare 0x… 0x… to score wallets separately)"})); return 2
730
+ else:
731
+ addr = a.address.strip()
732
+ if not ADDR_RE.match(addr):
733
+ print(json.dumps({"error": "not a Hyperliquid address — expected 0x followed by 40 hex characters"})); return 2
734
+ addr = addr.lower()
735
+ # Whose book this is comes from the request alone. The address book that used to remember it is
736
+ # gone: the runtime owns run history now, and a remembered mark once re-voiced a reader's own
737
+ # book to the third person. The flagship path is a reader pasting their own address, so "mine"
738
+ # is the default and --other is the explicit analyst read.
739
+ whose = "other" if a.other else "mine"
740
+ # A BOOK is keyed on its whole SET, not on its first wallet. Keying on wallets[0] made
741
+ # `desk.py A` and `desk.py --book A B` share desk-A.json for the 10-minute freshness window, so
742
+ # whichever ran first was served as the other: a single wallet returned as "across 2 wallets"
743
+ # (11,594 fills), or a two-wallet book returned as A alone (5,797). `--compare` reads the same
744
+ # file for an hour, so a book also surfaced as its first wallet's column. (@shnoodles, #773.)
745
+ _state_key = ("book-" + hashlib.sha1("|".join(sorted(wallets)).encode()).hexdigest()[:16]
746
+ if wallets else addr)
747
+ state_path = os.path.join(a.state_dir, f"desk-{_state_key}.json")
748
+ meta = {}
749
+ bench = None
750
+ if os.path.exists(BENCH_PATH):
751
+ with open(BENCH_PATH) as fh:
752
+ bench = json.load(fh).get("benchmark")
753
+ r = None
754
+ if (a.section or a.deep) and not a.fresh and os.path.exists(state_path) and time.time() - os.path.getmtime(state_path) < FRESH_S:
755
+ with open(state_path) as fh:
756
+ r = json.load(fh)
757
+ if r is None:
758
+ if a.fixture:
759
+ with open(a.fixture) as fh:
760
+ rec = json.load(fh)
761
+ hl = hl_api.HLFixture(rec); mcp = _MCPFixture(rec) if any(k.startswith("discovery_") for k in rec) else None
762
+ else:
763
+ if a.dry:
764
+ print(json.dumps({"error": "--dry needs --fixture"})); return 2
765
+ hl = hl_api.HL(cache_dir=a.cache or None); hl.progress = log; mcp = _mcp_client(meta)
766
+ # ---- is this even a trader? One call, before ~200. See hl_api.subject().
767
+ subj = hl.subject(addr) if not a.fixture else {"role": "user"}
768
+ if subj.get("role") == "vault" and not a.force:
769
+ nm = subj.get("name") or "a vault"
770
+ desc = (subj.get("description") or "").strip()
771
+ print(json.dumps({
772
+ "not_a_trader": "vault",
773
+ "name": subj.get("name"), "description": desc or None, "address": addr,
774
+ "error": f"{nm} is a Hyperliquid VAULT, not a trader's wallet — the desk reads how "
775
+ f"someone trades, and a vault is a pooled book run by its leader.",
776
+ "say_to_the_reader": (
777
+ f"That address is **{nm}**"
778
+ + (f" — {desc[0].lower() + desc[1:]}" if desc else "")
779
+ + " A trader scorecard does not describe it: there is no entry thesis to time, "
780
+ "no stop to place, and the P&L belongs to its depositors rather than to one "
781
+ "trader. Want me to run the desk on your own wallet instead?"),
782
+ "if_you_meant_it": "re-run with --force to read it as a trader anyway",
783
+ "leader": subj.get("leader")}, indent=2))
784
+ return 4
785
+ log(f"[quant-desk] running senpi quant desk on {addr[:6]}…{addr[-4:]}")
786
+ try:
787
+ r = analyze(addr, hl, days=a.days, mcp=mcp, want_rank=not a.no_rank and not wallets,
788
+ want_cohort=not a.no_cohort, bench=bench, meta=meta, whose=whose,
789
+ wallets=wallets, force=a.force)
790
+ except NotATraderError as e:
791
+ # same exit code as the vault gate: both mean "this address is not a trader's book",
792
+ # and an agent should treat them identically.
793
+ print(json.dumps(e.payload, indent=2)); return 4
794
+ except hl_api.HLError as e:
795
+ # A 429 here is the venue's rate bucket, not a broken wallet, and it is the one failure
796
+ # a reader can act on — so say which it was rather than printing the raw exception.
797
+ rate = "429" in str(e)
798
+ print(json.dumps({
799
+ "error": f"Hyperliquid read failed: {e}",
800
+ **({"wallets": wallets} if wallets else {"address": addr}),
801
+ "rate_limited": rate,
802
+ "what_to_do": ("Hyperliquid rate-limited this box. Wait about a minute and run it "
803
+ "ONCE more — do not launch a second run while one is in flight, that "
804
+ "is what exhausts the budget.") if rate else
805
+ "a transient read failure — one retry is worth it"})); return 1
806
+ if not r["activity"]["fills"] and not r["book"]["positions"] and wallets:
807
+ # A book that reads empty is a different dead end: this reader already resolved their
808
+ # wallets, so pointing them back at strategy_list is noise.
809
+ print(json.dumps({
810
+ "error": f"no PERP activity in the last {a.days} days across any of these "
811
+ f"{len(wallets)} wallets, and no open perp positions. "
812
+ f"Spot trades and transfers are not perp activity and are not read here.",
813
+ "what_this_usually_means": "a strategy that was funded but never filled has no history "
814
+ "to read, and a wallet that only ever held spot or moved "
815
+ "funds has none either. If some of these were closed "
816
+ "strategies, they may simply predate the window.",
817
+ "wallets": wallets, "days": a.days, "indexed": r.get("indexed"),
818
+ "perp_fills_in_window": 0, "open_perp_positions": 0})); return 3
819
+ if not r["activity"]["fills"] and not r["book"]["positions"]:
820
+ # Carry `indexed` out even here. Without it a caller cannot tell "senpi has never seen this
821
+ # wallet" from "senpi has it and there is simply nothing in the window" — and those two need
822
+ # opposite things said to the reader. Spot fills do not count as perp activity, so a wallet
823
+ # the owner knows is busy can land here; say which it is rather than "nothing to read".
824
+ print(json.dumps({
825
+ # A senpi user's embedded wallet is a FUNDING wallet — deposits land there and move
826
+ # out to the strategy subwallets that actually trade. Two of four users on launch
827
+ # night were pointed here by their own agent and told their book was empty, on books
828
+ # that trade daily. The guidance is fixed in SKILL.md; this says it too, so the
829
+ # dead end corrects itself even when the wrong wallet is picked.
830
+ "error": f"no PERP activity in the last {a.days} days and no open perp positions. "
831
+ f"Spot trades and transfers are not perp activity and are not read here.",
832
+ "if_this_is_your_own_wallet": "If you trade through senpi, your perp history is in your "
833
+ "STRATEGY wallets, not this one — an embedded wallet is the "
834
+ "funding wallet. Resolve them with strategy_list and run the "
835
+ "desk on those.",
836
+ "address": addr, "days": a.days, "indexed": r.get("indexed"),
837
+ "perp_fills_in_window": 0, "open_perp_positions": 0})); return 3
838
+ log(f"[quant-desk] done in {meta['timings']['total']}s ({meta.get('hl_calls')} reads)")
839
+ # atomic: stage 1 writes this and stages 2-4 read it, so a half-written relay file breaks
840
+ # the whole staged run — and JSONDecodeError is not an HLError, so the handler above misses it
841
+ hl_api._atomic_json(state_path, json.loads(json.dumps(r, default=float)))
842
+ if a.deep:
843
+ candles = {}
844
+ if a.deep in ("protect", "replay"):
845
+ if a.fixture:
846
+ with open(a.fixture) as fh:
847
+ hl = hl_api.HLFixture(json.load(fh))
848
+ else:
849
+ hl = hl_api.HL(cache_dir=a.cache or None); hl.progress = log
850
+ coins = sorted({p["coin"] for p in r["book"]["positions"]} | {e["coin"] for e in r["episodes"]})
851
+ try:
852
+ candles = timing_mod.load_candles(hl.candles(coins, days=a.days + 1))
853
+ except Exception as e: # noqa: BLE001
854
+ log(f"[quant-desk] candles unavailable for the deep dive: {e}")
855
+ data = {"protect": lambda: deep_mod.protect(r, candles), "replay": lambda: deep_mod.replay(r, candles), "funding": lambda: deep_mod.funding_forecast(r),
856
+ "compare": lambda: deep_mod.compare_windows(r), "rules": lambda: deep_mod.rules(r), "regime": lambda: deep_mod.regime(r), "watch": lambda: deep_mod.watch(r),
857
+ "smart": lambda: dict(cohorts=r.get("cohorts") or []), "scout": lambda: dict(opportunities=r.get("opportunities") or [], setups=r.get("setups")),
858
+ "strategy": lambda: r.get("strategy") or {}}[a.deep]()
859
+ if a.json:
860
+ print(json.dumps(data, default=float))
861
+ else:
862
+ md = render.render_deep(a.deep, data, r)
863
+ if r.get("whose") == "other":
864
+ import voice
865
+ md = voice.third_person(md, f"{addr[:6]}…{addr[-4:]}")
866
+ print(md)
867
+ return 0
868
+ if (a.other or a.mine) and r.get("whose") != whose:
869
+ r["whose"] = whose; r["followups"] = followups.offer(r, whose=whose); r["cards"] = render.cards(r) # a cached run re-voiced
870
+ if a.json:
871
+ print(json.dumps({k: v for k, v in r.items() if k != "episodes"}, default=float))
872
+ else:
873
+ print(render.render(r, a.section))
874
+ return 0
875
+
876
+
877
+ if __name__ == "__main__":
878
+ sys.exit(main())