@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.
- 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 +1 -1
- 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,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())
|