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