@senpi/runtime 3.0.146 → 3.0.149
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +55 -0
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/quant-command.d.ts +128 -0
- package/dist/cli/quant-command.d.ts.map +1 -0
- package/dist/cli/quant-command.js +345 -0
- package/dist/cli/quant-command.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts +11 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +49 -4
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -1
- package/dist/quant/engine/quantdesk/__init__.py +19 -0
- package/dist/quant/engine/quantdesk/__main__.py +281 -0
- package/dist/quant/engine/quantdesk/_version.py +24 -0
- package/dist/quant/engine/quantdesk/benchmark.py +90 -0
- package/dist/quant/engine/quantdesk/book.py +197 -0
- package/dist/quant/engine/quantdesk/deep.py +197 -0
- package/dist/quant/engine/quantdesk/desk.py +878 -0
- package/dist/quant/engine/quantdesk/dsl.py +224 -0
- package/dist/quant/engine/quantdesk/followups.py +117 -0
- package/dist/quant/engine/quantdesk/hl_api.py +473 -0
- package/dist/quant/engine/quantdesk/market.py +290 -0
- package/dist/quant/engine/quantdesk/mcp_client.py +158 -0
- package/dist/quant/engine/quantdesk/metrics.py +409 -0
- package/dist/quant/engine/quantdesk/opportunities.py +88 -0
- package/dist/quant/engine/quantdesk/render.py +787 -0
- package/dist/quant/engine/quantdesk/roundtrips.py +124 -0
- package/dist/quant/engine/quantdesk/score.py +817 -0
- package/dist/quant/engine/quantdesk/senpi_history.py +160 -0
- package/dist/quant/engine/quantdesk/smart_money.py +354 -0
- package/dist/quant/engine/quantdesk/strategy_read.py +292 -0
- package/dist/quant/engine/quantdesk/taxonomy.py +55 -0
- package/dist/quant/engine/quantdesk/timing.py +164 -0
- package/dist/quant/engine/quantdesk/voice.py +42 -0
- package/dist/quant/engine-runner.d.ts +133 -0
- package/dist/quant/engine-runner.d.ts.map +1 -0
- package/dist/quant/engine-runner.js +306 -0
- package/dist/quant/engine-runner.js.map +1 -0
- package/dist/quant/job.d.ts +125 -0
- package/dist/quant/job.d.ts.map +1 -0
- package/dist/quant/job.js +184 -0
- package/dist/quant/job.js.map +1 -0
- package/dist/quant/register.d.ts +53 -0
- package/dist/quant/register.d.ts.map +1 -0
- package/dist/quant/register.js +253 -0
- package/dist/quant/register.js.map +1 -0
- package/dist/quant/registry.d.ts +105 -0
- package/dist/quant/registry.d.ts.map +1 -0
- package/dist/quant/registry.js +248 -0
- package/dist/quant/registry.js.map +1 -0
- package/dist/quant/types.d.ts +216 -0
- package/dist/quant/types.d.ts.map +1 -0
- package/dist/quant/types.js +121 -0
- package/dist/quant/types.js.map +1 -0
- package/dist/skills-manager/manifest.js +1 -1
- package/dist/skills-manager/skills-manifest.json +73 -16
- package/dist/widgets/index.d.ts +8 -1
- package/dist/widgets/index.d.ts.map +1 -1
- package/dist/widgets/index.js +1 -0
- package/dist/widgets/index.js.map +1 -1
- package/dist/widgets/registry.d.ts +8 -0
- package/dist/widgets/registry.d.ts.map +1 -1
- package/dist/widgets/registry.js +26 -1
- package/dist/widgets/registry.js.map +1 -1
- package/dist/widgets/tool.d.ts +14 -4
- package/dist/widgets/tool.d.ts.map +1 -1
- package/dist/widgets/tool.js +36 -23
- package/dist/widgets/tool.js.map +1 -1
- package/dist/widgets/turn-cap.d.ts +33 -0
- package/dist/widgets/turn-cap.d.ts.map +1 -0
- package/dist/widgets/turn-cap.js +119 -0
- package/dist/widgets/turn-cap.js.map +1 -0
- package/dist/widgets/types.d.ts +33 -5
- package/dist/widgets/types.d.ts.map +1 -1
- package/dist/widgets/types.js +4 -3
- package/dist/widgets/types.js.map +1 -1
- package/dist/widgets/widgets/fund-user-wallet.d.ts.map +1 -1
- package/dist/widgets/widgets/fund-user-wallet.js +1 -0
- package/dist/widgets/widgets/fund-user-wallet.js.map +1 -1
- package/dist/widgets/widgets/quant-desk-recommendations.d.ts +52 -0
- package/dist/widgets/widgets/quant-desk-recommendations.d.ts.map +1 -0
- package/dist/widgets/widgets/quant-desk-recommendations.js +278 -0
- package/dist/widgets/widgets/quant-desk-recommendations.js.map +1 -0
- package/openclaw.plugin.json +12 -2
- package/package.json +4 -3
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""`python3 -m quantdesk` — the runtime's fixed interface to the desk engine.
|
|
2
|
+
|
|
3
|
+
python3 -m quantdesk run --config <config.json> --out <result.json>
|
|
4
|
+
python3 -m quantdesk render --doc <run.json> [--section S ...] --out <result.json>
|
|
5
|
+
|
|
6
|
+
The runtime owns this interface, not desk.py's flags. Results travel by FILE: the result document
|
|
7
|
+
lands in --out, progress goes to stderr, and stdout carries nothing (desk.main's own prints are
|
|
8
|
+
captured here). A result file always states `exit_code`; the process exits with the same code.
|
|
9
|
+
The result file is strict JSON: a non-finite float (an all-winning bucket's profit factor is
|
|
10
|
+
inf) is written as the string "Infinity", "-Infinity" or "NaN", and `render` reads them back.
|
|
11
|
+
|
|
12
|
+
0 {"document": <the desk's --json document, with cards and doc_version>}
|
|
13
|
+
1 {"refusal": ...} Hyperliquid read failed (HLError)
|
|
14
|
+
2 {"refusal": ...} invalid request (bad address, malformed config, argparse)
|
|
15
|
+
3 {"refusal": ...} no perp activity in the window
|
|
16
|
+
4 {"refusal": ...} not a trader's book (vault, or wider than the desk reads)
|
|
17
|
+
70 {"crash": ...} anything else — a fault in the engine, never in the wallet
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import contextlib
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
import traceback
|
|
29
|
+
|
|
30
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
31
|
+
if HERE not in sys.path:
|
|
32
|
+
sys.path.insert(0, HERE)
|
|
33
|
+
|
|
34
|
+
import desk # noqa: E402 flat on purpose: one module object, shared with the tests
|
|
35
|
+
import render # noqa: E402
|
|
36
|
+
|
|
37
|
+
ENGINE_CRASH_EXIT = 70
|
|
38
|
+
|
|
39
|
+
# Every key the runtime may send. An unknown key is refused rather than ignored: a typo such as
|
|
40
|
+
# `froce` would otherwise run a different desk from the one the caller asked for, silently.
|
|
41
|
+
CONFIG_KEYS = frozenset({"addresses", "days", "whose", "book", "force", "state_dir", "fixture"})
|
|
42
|
+
|
|
43
|
+
# How a non-finite float crosses the file boundary. See _to_strict_json.
|
|
44
|
+
_NON_FINITE_OUT = {math.inf: "Infinity", -math.inf: "-Infinity"}
|
|
45
|
+
_NON_FINITE_IN = {"Infinity": math.inf, "-Infinity": -math.inf, "NaN": math.nan}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# The longest TTL in hl_api (userRole / vaultDetails). A cache file older than this can never be a
|
|
49
|
+
# hit again, and the desk's scratch (desk-*.json) is per-run; both are safe to delete past it.
|
|
50
|
+
SCRATCH_MAX_AGE_S = 7 * 24 * 3600
|
|
51
|
+
# The subdirectories of <state_dir> the engine writes and prunes. The runtime's registry files sit
|
|
52
|
+
# next to them and are never touched here.
|
|
53
|
+
SCRATCH_DIRS = ("cache", "engine")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ConfigError(ValueError):
|
|
57
|
+
"""The runtime handed the engine a config it cannot act on — exit 2, never a traceback."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_argv(cfg):
|
|
61
|
+
"""Translate the runtime's config into desk.main's argv.
|
|
62
|
+
|
|
63
|
+
The desk's own scratch state (desk-*.json) goes to <state_dir>/engine and its HTTP cache to
|
|
64
|
+
<state_dir>/cache, so the runtime's registry files under <state_dir> stay the only other thing
|
|
65
|
+
there. `fresh` is not passed: freshness is the runtime registry's rule, and a `run` without
|
|
66
|
+
--section/--deep always recomputes.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(cfg, dict):
|
|
69
|
+
raise ConfigError("config must be a JSON object")
|
|
70
|
+
unknown = sorted(set(cfg) - CONFIG_KEYS)
|
|
71
|
+
if unknown:
|
|
72
|
+
raise ConfigError(f"config has unknown keys {unknown}; expected only {sorted(CONFIG_KEYS)}")
|
|
73
|
+
# Real booleans only: a truthiness test turns the string "false" into --book / --force.
|
|
74
|
+
for flag in ("book", "force"):
|
|
75
|
+
if flag in cfg and not isinstance(cfg[flag], bool):
|
|
76
|
+
raise ConfigError(f"config.{flag} must be true or false")
|
|
77
|
+
if "fixture" in cfg and cfg["fixture"] is not None and not isinstance(cfg["fixture"], str):
|
|
78
|
+
raise ConfigError("config.fixture must be a path string")
|
|
79
|
+
addresses = cfg.get("addresses")
|
|
80
|
+
if not isinstance(addresses, list) or not addresses or not all(isinstance(a, str) and a for a in addresses):
|
|
81
|
+
raise ConfigError("config.addresses must be a non-empty list of addresses")
|
|
82
|
+
state_dir = cfg.get("state_dir")
|
|
83
|
+
if not isinstance(state_dir, str) or not state_dir:
|
|
84
|
+
raise ConfigError("config.state_dir is required")
|
|
85
|
+
whose = cfg.get("whose", "mine")
|
|
86
|
+
if whose not in ("mine", "other"):
|
|
87
|
+
raise ConfigError('config.whose must be "mine" or "other"')
|
|
88
|
+
days = cfg.get("days", 90)
|
|
89
|
+
if isinstance(days, bool) or not isinstance(days, int) or days < 1:
|
|
90
|
+
raise ConfigError("config.days must be a positive integer")
|
|
91
|
+
argv = ["--book", *addresses] if (cfg.get("book") or len(addresses) > 1) else [addresses[0]]
|
|
92
|
+
argv += ["--other" if whose == "other" else "--mine", "--days", str(days)]
|
|
93
|
+
if cfg.get("force"):
|
|
94
|
+
argv.append("--force")
|
|
95
|
+
argv += ["--state-dir", os.path.join(state_dir, "engine"), "--cache", os.path.join(state_dir, "cache"), "--json"]
|
|
96
|
+
if cfg.get("fixture"):
|
|
97
|
+
argv += ["--fixture", cfg["fixture"], "--dry"]
|
|
98
|
+
return argv
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _to_strict_json(obj):
|
|
102
|
+
"""Replace non-finite floats with the strings "Infinity", "-Infinity" and "NaN", recursively.
|
|
103
|
+
|
|
104
|
+
Why: the desk's figures legitimately include infinity — a profit factor on a bucket with no
|
|
105
|
+
losing trade is `float("inf")` (metrics, strategy_read, timing, market, score, opportunities,
|
|
106
|
+
deep) — and Python's json writes that as the bare token `Infinity`, which is not JSON. Node's
|
|
107
|
+
`JSON.parse` rejects the whole file, so the runtime would read every such successful desk as an
|
|
108
|
+
engine fault. Strings keep the value readable on both sides; `_from_strict_json` restores them
|
|
109
|
+
before anything in Python renders the document again.
|
|
110
|
+
"""
|
|
111
|
+
if isinstance(obj, float):
|
|
112
|
+
if math.isnan(obj):
|
|
113
|
+
return "NaN"
|
|
114
|
+
return _NON_FINITE_OUT.get(obj, obj)
|
|
115
|
+
if isinstance(obj, dict):
|
|
116
|
+
return {k: _to_strict_json(v) for k, v in obj.items()}
|
|
117
|
+
if isinstance(obj, (list, tuple)):
|
|
118
|
+
return [_to_strict_json(v) for v in obj]
|
|
119
|
+
return obj
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _from_strict_json(obj):
|
|
123
|
+
"""The inverse of `_to_strict_json`: the three marker strings become float inf/-inf/nan again.
|
|
124
|
+
|
|
125
|
+
Why: render.py formats `inf` as "∞" and compares against `float("inf")`; a re-render from a
|
|
126
|
+
stored run must produce the same Markdown as the in-process render did. The cost is that a
|
|
127
|
+
genuine string field whose whole value is "NaN" or "Infinity" would be read back as a float; no
|
|
128
|
+
field of the desk document is such a string (coins, labels and prose never are).
|
|
129
|
+
"""
|
|
130
|
+
if isinstance(obj, str):
|
|
131
|
+
return _NON_FINITE_IN.get(obj, obj)
|
|
132
|
+
if isinstance(obj, dict):
|
|
133
|
+
return {k: _from_strict_json(v) for k, v in obj.items()}
|
|
134
|
+
if isinstance(obj, list):
|
|
135
|
+
return [_from_strict_json(v) for v in obj]
|
|
136
|
+
return obj
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _non_finite_card_fields(doc):
|
|
140
|
+
"""Card fields holding a non-finite number. Always empty today (a leak's usd is a finite sum);
|
|
141
|
+
checked so a future card never ships "Infinity" as a dollar figure to a client."""
|
|
142
|
+
return [f"{c.get('id')}.{k}" for c in doc.get("cards") or [] for k, v in c.items()
|
|
143
|
+
if isinstance(v, float) and not math.isfinite(v)]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _write(out_path, obj):
|
|
147
|
+
"""Write the result file atomically (write-then-rename), as STRICT JSON.
|
|
148
|
+
|
|
149
|
+
`allow_nan=False` after `_to_strict_json` makes a missed non-finite float a loud ValueError here
|
|
150
|
+
rather than a file the runtime cannot parse. The rename means the runtime never reads a
|
|
151
|
+
half-written result.
|
|
152
|
+
"""
|
|
153
|
+
text = json.dumps(_to_strict_json(obj), allow_nan=False)
|
|
154
|
+
d = os.path.dirname(out_path) or "."
|
|
155
|
+
os.makedirs(d, exist_ok=True)
|
|
156
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".quantdesk.", suffix=".json")
|
|
157
|
+
try:
|
|
158
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
159
|
+
fh.write(text)
|
|
160
|
+
os.replace(tmp, out_path)
|
|
161
|
+
finally:
|
|
162
|
+
if os.path.exists(tmp):
|
|
163
|
+
os.unlink(tmp)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def prune_scratch(state_dir, now=None, max_age_s=SCRATCH_MAX_AGE_S):
|
|
167
|
+
"""Delete files older than `max_age_s` under <state_dir>/cache and <state_dir>/engine.
|
|
168
|
+
|
|
169
|
+
Why: hl_api never deletes a cache file — it only ignores one past its TTL — and its keys are
|
|
170
|
+
time-bucketed (`now_ms` floored to 5 minutes goes into every expensive read's body), so every
|
|
171
|
+
desk writes new keys and the cache grows without bound on a long-lived agent box. Seven days is
|
|
172
|
+
the longest TTL, so nothing deleted here could still have been a hit.
|
|
173
|
+
|
|
174
|
+
Best-effort by contract: an unreadable directory or a file that cannot be deleted is skipped,
|
|
175
|
+
never raised — pruning is housekeeping and is never a reason for a desk to fail. Prints
|
|
176
|
+
`[quant-desk] cache: pruned N files` to stderr (the progress stream) only when N > 0, so a
|
|
177
|
+
normal run's progress stays unchanged; stdout is never written.
|
|
178
|
+
|
|
179
|
+
Returns the number of files deleted.
|
|
180
|
+
"""
|
|
181
|
+
cutoff = (time.time() if now is None else now) - max_age_s
|
|
182
|
+
pruned = 0
|
|
183
|
+
for sub in SCRATCH_DIRS:
|
|
184
|
+
for root, _dirs, files in os.walk(os.path.join(state_dir, sub)):
|
|
185
|
+
for name in files:
|
|
186
|
+
path = os.path.join(root, name)
|
|
187
|
+
try:
|
|
188
|
+
if os.path.getmtime(path) < cutoff:
|
|
189
|
+
os.unlink(path)
|
|
190
|
+
pruned += 1
|
|
191
|
+
except OSError:
|
|
192
|
+
continue
|
|
193
|
+
if pruned:
|
|
194
|
+
print(f"[quant-desk] cache: pruned {pruned} files", file=sys.stderr, flush=True)
|
|
195
|
+
return pruned
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run(config_path, out_path):
|
|
199
|
+
"""Run one desk from a config file and write the result file. Returns the exit code.
|
|
200
|
+
|
|
201
|
+
Before the desk starts, scratch older than seven days is pruned (`prune_scratch`); a failure
|
|
202
|
+
there never changes the outcome of the run.
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
with open(config_path, encoding="utf-8") as fh:
|
|
206
|
+
cfg = json.load(fh)
|
|
207
|
+
argv = build_argv(cfg)
|
|
208
|
+
except (OSError, ValueError) as e: # ConfigError and JSONDecodeError are ValueErrors
|
|
209
|
+
_write(out_path, {"exit_code": 2, "refusal": {"error": f"invalid engine config: {e}"}})
|
|
210
|
+
return 2
|
|
211
|
+
try:
|
|
212
|
+
prune_scratch(cfg["state_dir"])
|
|
213
|
+
except Exception: # noqa: BLE001 housekeeping never fails a desk
|
|
214
|
+
pass
|
|
215
|
+
buf = io.StringIO()
|
|
216
|
+
try:
|
|
217
|
+
with contextlib.redirect_stdout(buf):
|
|
218
|
+
rc = desk.main(argv)
|
|
219
|
+
except SystemExit as e: # argparse refusing the argv
|
|
220
|
+
rc = e.code if isinstance(e.code, int) else 2
|
|
221
|
+
except Exception as e: # noqa: BLE001
|
|
222
|
+
traceback.print_exc()
|
|
223
|
+
_write(out_path, {"exit_code": ENGINE_CRASH_EXIT,
|
|
224
|
+
"crash": {"error": f"{type(e).__name__}: {e}",
|
|
225
|
+
"traceback": traceback.format_exc().splitlines()[-20:]}})
|
|
226
|
+
return ENGINE_CRASH_EXIT
|
|
227
|
+
text = buf.getvalue().strip()
|
|
228
|
+
try:
|
|
229
|
+
body = json.loads(text) if text else None
|
|
230
|
+
except ValueError:
|
|
231
|
+
body = None
|
|
232
|
+
if rc == 0:
|
|
233
|
+
if not isinstance(body, dict):
|
|
234
|
+
_write(out_path, {"exit_code": ENGINE_CRASH_EXIT,
|
|
235
|
+
"crash": {"error": "the desk finished but printed no JSON document", "stdout_tail": text[-2000:]}})
|
|
236
|
+
return ENGINE_CRASH_EXIT
|
|
237
|
+
bad = _non_finite_card_fields(body)
|
|
238
|
+
if bad:
|
|
239
|
+
_write(out_path, {"exit_code": ENGINE_CRASH_EXIT,
|
|
240
|
+
"crash": {"error": f"cards carry non-finite figures: {bad}"}})
|
|
241
|
+
return ENGINE_CRASH_EXIT
|
|
242
|
+
_write(out_path, {"exit_code": 0, "document": body})
|
|
243
|
+
return 0
|
|
244
|
+
_write(out_path, {"exit_code": rc, "refusal": body if isinstance(body, dict) else {"error": text[-2000:] or "no detail"}})
|
|
245
|
+
return rc
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def render_doc(doc_path, sections, out_path):
|
|
249
|
+
"""Re-render sections from a stored run (or a bare document). No network, no recompute."""
|
|
250
|
+
try:
|
|
251
|
+
with open(doc_path, encoding="utf-8") as fh:
|
|
252
|
+
stored = json.load(fh)
|
|
253
|
+
except (OSError, ValueError) as e:
|
|
254
|
+
_write(out_path, {"exit_code": 2, "refusal": {"error": f"unreadable stored run: {e}"}})
|
|
255
|
+
return 2
|
|
256
|
+
doc = stored.get("document") if isinstance(stored, dict) and "document" in stored else stored
|
|
257
|
+
if not isinstance(doc, dict):
|
|
258
|
+
_write(out_path, {"exit_code": 2, "refusal": {"error": "the stored run has no document"}})
|
|
259
|
+
return 2
|
|
260
|
+
_write(out_path, {"exit_code": 0, "markdown": render.render(_from_strict_json(doc), sections or None)})
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def main(argv=None):
|
|
265
|
+
ap = argparse.ArgumentParser(prog="python3 -m quantdesk", description="Senpi quant desk engine (runtime interface)")
|
|
266
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
267
|
+
r = sub.add_parser("run", help="run one desk from a config file")
|
|
268
|
+
r.add_argument("--config", required=True)
|
|
269
|
+
r.add_argument("--out", required=True)
|
|
270
|
+
d = sub.add_parser("render", help="render sections of a stored run")
|
|
271
|
+
d.add_argument("--doc", required=True)
|
|
272
|
+
d.add_argument("--section", action="append", choices=render.SECTIONS)
|
|
273
|
+
d.add_argument("--out", required=True)
|
|
274
|
+
a = ap.parse_args(argv)
|
|
275
|
+
if a.cmd == "run":
|
|
276
|
+
return run(a.config, a.out)
|
|
277
|
+
return render_doc(a.doc, a.section, a.out)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
sys.exit(main())
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The engine's version is the runtime package's version.
|
|
2
|
+
|
|
3
|
+
The desk used to carry its own literal (1.38.0), pinned by a test to the skill's frontmatter so a
|
|
4
|
+
stale install was visible. Inside @senpi-ai/runtime the engine cannot be stale on its own: it ships
|
|
5
|
+
in the same tarball as the TypeScript that spawns it. Reading package.json (four levels up from
|
|
6
|
+
here in both `src/quant/engine/quantdesk` and `dist/quant/engine/quantdesk`) keeps one number, and
|
|
7
|
+
the release job's version bump cannot break a pin.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
_PKG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "..", "package.json")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read():
|
|
16
|
+
try:
|
|
17
|
+
with open(_PKG, encoding="utf-8") as fh:
|
|
18
|
+
v = json.load(fh).get("version")
|
|
19
|
+
return v if isinstance(v, str) and v else "0.0.0"
|
|
20
|
+
except (OSError, ValueError):
|
|
21
|
+
return "0.0.0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
VERSION = _read()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Recompute benchmark.json (next to the engine) — the whale medians the desk compares a trader against.
|
|
3
|
+
|
|
4
|
+
With `SENPI_AUTH_TOKEN` (the only source that works for whales): the cohort is Senpi discovery's ≥ $1M-realized
|
|
5
|
+
ranking and each member's closed positions come from `discovery_get_trader_history` — complete. Without a
|
|
6
|
+
token the script falls back to the public leaderboard's large accounts read through the public engine, but
|
|
7
|
+
whales are TWAP-heavy and the public endpoints keep only the most recent slices, so their round trips come
|
|
8
|
+
back 0–1 per wallet: that run is diagnostic only, and the desk hides the whale-median table when the
|
|
9
|
+
benchmark has fewer than 5 members with ≥ 10 trades.
|
|
10
|
+
|
|
11
|
+
SENPI_AUTH_TOKEN=… python3 benchmark.py [--n 40] [--days 90] [--out benchmark.json (next to the engine)]
|
|
12
|
+
"""
|
|
13
|
+
# Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import statistics
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
22
|
+
sys.path.insert(0, HERE)
|
|
23
|
+
import hl_api # noqa: E402
|
|
24
|
+
import metrics # noqa: E402
|
|
25
|
+
import senpi_history # noqa: E402
|
|
26
|
+
import smart_money # noqa: E402
|
|
27
|
+
from roundtrips import episodes_from_fills # noqa: E402
|
|
28
|
+
|
|
29
|
+
KEYS = ("win_rate", "profit_factor", "hold_winners_h", "hold_losers_h", "hold_ratio", "cost_ratio", "taker_share", "size_cv", "payoff_ratio", "long_share")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def member(hl, addr, days, mcp=None):
|
|
33
|
+
win_start = hl.now_ms - days * hl_api.DAY_MS
|
|
34
|
+
start = hl.now_ms - (days + 60) * hl_api.DAY_MS
|
|
35
|
+
fills = hl_api.merge_fills(hl.fills(addr, start), hl.twap_slices(addr, start))
|
|
36
|
+
closed, opened = episodes_from_fills(fills)
|
|
37
|
+
if mcp is not None:
|
|
38
|
+
rows = senpi_history.fetch(mcp, addr, win_start, {})
|
|
39
|
+
if rows:
|
|
40
|
+
closed = rows
|
|
41
|
+
tr = metrics.track_record(closed, opened, hl.funding(addr, win_start), hl.info({"type": "userFees", "user": addr}), win_start)
|
|
42
|
+
out = {k: tr.get(k) for k in KEYS}
|
|
43
|
+
out.update(trades=tr["trades"], complete_trades=tr["complete_trades"], liquidations=tr["liquidations"], fills=len(fills))
|
|
44
|
+
out["coverage"] = metrics.coverage(closed, opened)["overall"]
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv=None):
|
|
49
|
+
ap = argparse.ArgumentParser()
|
|
50
|
+
ap.add_argument("--n", type=int, default=40); ap.add_argument("--days", type=int, default=90)
|
|
51
|
+
ap.add_argument("--out", default=os.path.join(HERE, "benchmark.json"))
|
|
52
|
+
a = ap.parse_args(argv)
|
|
53
|
+
hl = hl_api.HL(timeout=90)
|
|
54
|
+
mcp = None
|
|
55
|
+
if os.environ.get("SENPI_AUTH_TOKEN"):
|
|
56
|
+
from mcp_client import MCPClient
|
|
57
|
+
mcp = MCPClient()
|
|
58
|
+
cohort = smart_money.senpi_cohort(mcp, {})[: a.n * 2]
|
|
59
|
+
print(f"cohort: Senpi discovery, {len(cohort)} wallets with ≥ $1M realized", file=sys.stderr)
|
|
60
|
+
else:
|
|
61
|
+
cohort = hl_api.public_cohort(hl.leaderboard(), n=a.n * 3) # candidates; accounts with no fills are skipped
|
|
62
|
+
print("cohort: public leaderboard — diagnostic only for whales (see the docstring)", file=sys.stderr)
|
|
63
|
+
members, t0 = [], time.time()
|
|
64
|
+
for i, addr in enumerate(cohort):
|
|
65
|
+
if len(members) >= a.n:
|
|
66
|
+
break
|
|
67
|
+
try:
|
|
68
|
+
m = member(hl, addr, a.days, mcp)
|
|
69
|
+
if not m["fills"] and not m["trades"]:
|
|
70
|
+
print(f"{i + 1:2d} no fills — skipped", file=sys.stderr, flush=True)
|
|
71
|
+
continue
|
|
72
|
+
members.append(m)
|
|
73
|
+
print(f"{len(members):2d}/{a.n} trades={m['trades']:3d} complete={m['complete_trades']:3d} coverage={m['coverage']}", file=sys.stderr, flush=True)
|
|
74
|
+
except hl_api.HLError as e:
|
|
75
|
+
print(f"{i + 1:2d} failed: {e}", file=sys.stderr, flush=True)
|
|
76
|
+
time.sleep(0.5)
|
|
77
|
+
valid = [m for m in members if m["trades"] >= 10]
|
|
78
|
+
def med(k):
|
|
79
|
+
xs = [m[k] for m in valid if m[k] is not None and m[k] != float("inf")]
|
|
80
|
+
return statistics.median(xs) if xs else None
|
|
81
|
+
bench = {k: med(k) for k in KEYS}
|
|
82
|
+
bench.update(n=len(valid), cohort=("Senpi discovery — wallets with ≥ $1M realized" if mcp is not None else f"top-{a.n} profitable accounts ≥ $1M on Hyperliquid's leaderboard"),
|
|
83
|
+
computed_at=time.strftime("%Y-%m-%d"), window_days=a.days, engine="quant-desk roundtrips/metrics", seconds=round(time.time() - t0))
|
|
84
|
+
with open(a.out, "w") as fh:
|
|
85
|
+
json.dump({"benchmark": bench, "members": members}, fh, indent=1)
|
|
86
|
+
print(json.dumps(bench, indent=1))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
main()
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""One desk over SEVERAL wallets — a senpi user's whole book, not one subwallet of it.
|
|
3
|
+
|
|
4
|
+
A senpi user does not have "a wallet". They have a funding wallet that never trades and N strategy
|
|
5
|
+
subwallets that do, opened and closed as strategies come and go. Running the desk on one of them
|
|
6
|
+
scores one strategy; the reader asked about their trading. `--book 0xA 0xB 0xC` reads each wallet,
|
|
7
|
+
then UNIONS them into a single `tr_raw` the rest of the desk cannot tell from one account's.
|
|
8
|
+
|
|
9
|
+
Union, not concatenation — four of the keys need real arithmetic:
|
|
10
|
+
|
|
11
|
+
* **episodes** are built PER WALLET and then unioned. `episodes_from_fills` tracks position per coin
|
|
12
|
+
through `startPosition`; concatenating two wallets that both trade BTC interleaves two position
|
|
13
|
+
tracks into one and the reconstruction is garbage. This is the reason this module exists.
|
|
14
|
+
* **portfolio** series are SUMMED on a union timestamp grid, step-interpolated. Before a wallet's
|
|
15
|
+
first sample it contributes 0 (it did not exist); after its last it carries its final value (a
|
|
16
|
+
closed strategy keeps whatever P&L it ended with). Equity and cumulative P&L are both correct
|
|
17
|
+
under that rule, and both are denominators — drawdown, return-on-equity, beta.
|
|
18
|
+
* **ledger** transfers BETWEEN wallets in the set are dropped. Moving $10k from one of your
|
|
19
|
+
strategies to another is not a deposit and not a withdrawal; left in, it lands in `flows()` twice
|
|
20
|
+
with opposite signs on the same book and makes the equity curve step for no reason.
|
|
21
|
+
* **dailyUserVlm** is summed by date, because the coverage diagnostic divides observed volume by it.
|
|
22
|
+
One wallet's denominator against three wallets' numerator reads as 300% coverage.
|
|
23
|
+
|
|
24
|
+
Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
|
|
25
|
+
"""
|
|
26
|
+
import collections
|
|
27
|
+
|
|
28
|
+
from roundtrips import episodes_from_fills
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _f(x, d=0.0):
|
|
32
|
+
try:
|
|
33
|
+
return float(x)
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return d
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sum_series(per_wallet):
|
|
39
|
+
"""Sum N [(t, value)] series onto the union of their timestamps.
|
|
40
|
+
|
|
41
|
+
Step-interpolated: each wallet holds its last value between its own samples. Outside its range
|
|
42
|
+
it contributes 0 BEFORE it opened and its final value AFTER it closed — which is what a wallet
|
|
43
|
+
that did not exist yet, and one that has been emptied, actually contributed to the book."""
|
|
44
|
+
grid = sorted({t for s in per_wallet for t, _ in s})
|
|
45
|
+
if not grid:
|
|
46
|
+
return []
|
|
47
|
+
cursors = [0] * len(per_wallet)
|
|
48
|
+
held = [0.0] * len(per_wallet)
|
|
49
|
+
out = []
|
|
50
|
+
for t in grid:
|
|
51
|
+
for i, s in enumerate(per_wallet):
|
|
52
|
+
while cursors[i] < len(s) and s[cursors[i]][0] <= t:
|
|
53
|
+
held[i] = s[cursors[i]][1]; cursors[i] += 1
|
|
54
|
+
out.append([t, sum(held)])
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _merge_portfolio(portfolios):
|
|
59
|
+
"""Sum every window of HL's `portfolio` response across wallets, series by series."""
|
|
60
|
+
names, by_name = [], collections.defaultdict(list)
|
|
61
|
+
for p in portfolios:
|
|
62
|
+
for name, win in (p or []):
|
|
63
|
+
if name not in by_name:
|
|
64
|
+
names.append(name)
|
|
65
|
+
by_name[name].append(win or {})
|
|
66
|
+
out = []
|
|
67
|
+
for name in names:
|
|
68
|
+
wins = by_name[name]
|
|
69
|
+
win = {}
|
|
70
|
+
for key in ("accountValueHistory", "pnlHistory"):
|
|
71
|
+
series = [[(t, _f(v)) for t, v in (w.get(key) or [])] for w in wins]
|
|
72
|
+
if any(series):
|
|
73
|
+
# back to HL's own shape: [ms, "string"] — every reader of this goes through _f()
|
|
74
|
+
win[key] = [[t, str(v)] for t, v in _sum_series(series)]
|
|
75
|
+
vlm = sum(_f(w.get("vlm")) for w in wins)
|
|
76
|
+
if vlm:
|
|
77
|
+
win["vlm"] = str(vlm)
|
|
78
|
+
out.append([name, win])
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _merge_fees(scheds):
|
|
83
|
+
"""The fee SCHEDULE of the wallet that traded most, with `dailyUserVlm` summed across all of them.
|
|
84
|
+
|
|
85
|
+
Rates are a tier, not a quantity: they do not add. Every dollar figure on the desk is anchored on
|
|
86
|
+
fees actually paid (summed from fills) and the schedule is only used to APPORTION those dollars
|
|
87
|
+
between taker and maker, so the busiest wallet's tier is the right representative. The daily
|
|
88
|
+
volume table is a quantity and does add — it is the denominator of the coverage diagnostic."""
|
|
89
|
+
live = [s for s in scheds if s]
|
|
90
|
+
if not live:
|
|
91
|
+
return None
|
|
92
|
+
def traded(s):
|
|
93
|
+
return sum(_f(d.get("userCross")) + _f(d.get("userAdd")) for d in (s.get("dailyUserVlm") or []))
|
|
94
|
+
out = dict(max(live, key=traded))
|
|
95
|
+
by_date = collections.defaultdict(lambda: [0.0, 0.0])
|
|
96
|
+
for s in live:
|
|
97
|
+
for d in (s.get("dailyUserVlm") or []):
|
|
98
|
+
row = by_date[d.get("date")]
|
|
99
|
+
row[0] += _f(d.get("userCross")); row[1] += _f(d.get("userAdd"))
|
|
100
|
+
if by_date:
|
|
101
|
+
out["dailyUserVlm"] = [{"date": d, "userCross": str(c), "userAdd": str(m)}
|
|
102
|
+
for d, (c, m) in sorted(by_date.items()) if d]
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _merge_clearinghouse(states, addrs=None):
|
|
107
|
+
"""Positions concatenated, margin summed. A merged book's margin is the sum of its wallets'.
|
|
108
|
+
|
|
109
|
+
Each position is stamped with the wallet it came from. Two strategies can hold the same coin,
|
|
110
|
+
and the protection audit matches resting stops to positions — by coin alone it would credit one
|
|
111
|
+
wallet's stop to the other's bare position and call a naked position protected."""
|
|
112
|
+
live = [(a, s) for a, s in zip(addrs or [None] * len(states), states) if s]
|
|
113
|
+
if not live:
|
|
114
|
+
return None
|
|
115
|
+
pos = []
|
|
116
|
+
for a, s in live:
|
|
117
|
+
for ap in (s.get("assetPositions") or []):
|
|
118
|
+
ap = dict(ap); ap["position"] = dict(ap["position"], wallet=a)
|
|
119
|
+
pos.append(ap)
|
|
120
|
+
live = [s for _a, s in live]
|
|
121
|
+
out = {"assetPositions": pos, "time": max((s.get("time") or 0) for s in live)}
|
|
122
|
+
for key in ("marginSummary", "crossMarginSummary"):
|
|
123
|
+
parts = [s.get(key) or {} for s in live]
|
|
124
|
+
fields = {k for p in parts for k in p}
|
|
125
|
+
if fields:
|
|
126
|
+
out[key] = {k: str(sum(_f(p.get(k)) for p in parts)) for k in fields}
|
|
127
|
+
for key in ("withdrawable", "crossMaintenanceMarginUsed"):
|
|
128
|
+
out[key] = str(sum(_f(s.get(key)) for s in live))
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _merge_spot(states):
|
|
133
|
+
live = [s for s in states if s]
|
|
134
|
+
if not live:
|
|
135
|
+
return None
|
|
136
|
+
bal = collections.defaultdict(lambda: [0.0, 0.0])
|
|
137
|
+
for s in live:
|
|
138
|
+
for b in (s.get("balances") or []):
|
|
139
|
+
row = bal[b.get("coin")]
|
|
140
|
+
row[0] += _f(b.get("total")); row[1] += _f(b.get("hold"))
|
|
141
|
+
return {"balances": [{"coin": c, "total": str(t), "hold": str(h)} for c, (t, h) in sorted(bal.items())]}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _internal(ledger, wallets):
|
|
145
|
+
"""Drop sends whose BOTH ends are wallets of this book — an internal move, not a flow."""
|
|
146
|
+
out = []
|
|
147
|
+
for x in ledger or []:
|
|
148
|
+
d = x.get("delta") or {}
|
|
149
|
+
if d.get("type") == "send":
|
|
150
|
+
if str(d.get("destination") or "").lower() in wallets and str(d.get("user") or "").lower() in wallets:
|
|
151
|
+
continue
|
|
152
|
+
out.append(x)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def read(hl, addrs, days=90, progress=None):
|
|
157
|
+
"""Read every wallet and return `(tr_raw, closed, opened, per_wallet)` for the union.
|
|
158
|
+
|
|
159
|
+
Sequential by design: Hyperliquid's Info API 429s on parallel reads from one client, and a
|
|
160
|
+
rate-limited wallet would silently drop out of somebody's book total."""
|
|
161
|
+
addrs = [a.strip().lower() for a in addrs]
|
|
162
|
+
seen, uniq = set(), []
|
|
163
|
+
for a in addrs:
|
|
164
|
+
if a not in seen:
|
|
165
|
+
seen.add(a); uniq.append(a)
|
|
166
|
+
raws, closed, opened, per_wallet = [], [], [], []
|
|
167
|
+
for i, a in enumerate(uniq, 1):
|
|
168
|
+
if progress:
|
|
169
|
+
progress(f"[quant-desk] · wallet {i} of {len(uniq)}: {a[:6]}…{a[-4:]} …")
|
|
170
|
+
tr = hl.trader(a, days=days)
|
|
171
|
+
raws.append(tr)
|
|
172
|
+
c, o = episodes_from_fills(tr["fills"])
|
|
173
|
+
for e in c + o:
|
|
174
|
+
e["wallet"] = a
|
|
175
|
+
closed.extend(c); opened.extend(o)
|
|
176
|
+
per_wallet.append(dict(address=a, fills=len(tr["fills"]), closed=len(c), open=len(o)))
|
|
177
|
+
first = raws[0]
|
|
178
|
+
wallets = set(uniq)
|
|
179
|
+
merged = {
|
|
180
|
+
"address": uniq[0], "wallets": uniq,
|
|
181
|
+
"now_ms": first["now_ms"], "window_start_ms": first["window_start_ms"],
|
|
182
|
+
"fetch_start_ms": first["fetch_start_ms"], "days": days,
|
|
183
|
+
"clearinghouseState": _merge_clearinghouse([r["clearinghouseState"] for r in raws], uniq),
|
|
184
|
+
"clearinghouseState_xyz": _merge_clearinghouse([r.get("clearinghouseState_xyz") for r in raws], uniq),
|
|
185
|
+
"frontendOpenOrders": [dict(o, wallet=a) for a, r in zip(uniq, raws) for o in (r["frontendOpenOrders"] or [])],
|
|
186
|
+
"frontendOpenOrders_xyz": [dict(o, wallet=a) for a, r in zip(uniq, raws) for o in (r.get("frontendOpenOrders_xyz") or [])],
|
|
187
|
+
"spotClearinghouseState": _merge_spot([r.get("spotClearinghouseState") for r in raws]),
|
|
188
|
+
"fills": sorted((f for r in raws for f in r["fills"]), key=lambda f: f["time"]),
|
|
189
|
+
"userFunding": sorted((dict(x, wallet=a) for a, r in zip(uniq, raws) for x in (r["userFunding"] or [])),
|
|
190
|
+
key=lambda x: x["time"]),
|
|
191
|
+
"userFees": _merge_fees([r["userFees"] for r in raws]),
|
|
192
|
+
"userFees_xyz": _merge_fees([r.get("userFees_xyz") for r in raws]),
|
|
193
|
+
"portfolio": _merge_portfolio([r["portfolio"] for r in raws]),
|
|
194
|
+
"ledger": sorted(_internal([x for r in raws for x in (r["ledger"] or [])], wallets), key=lambda x: x["time"]),
|
|
195
|
+
}
|
|
196
|
+
closed.sort(key=lambda e: e.get("close_time") or e.get("open_time") or 0)
|
|
197
|
+
return merged, closed, opened, per_wallet
|