@senpi/runtime 3.0.148 → 3.0.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/cli/guide-commands.d.ts.map +1 -1
  2. package/dist/cli/guide-commands.js +55 -0
  3. package/dist/cli/guide-commands.js.map +1 -1
  4. package/dist/cli/quant-command.d.ts +128 -0
  5. package/dist/cli/quant-command.d.ts.map +1 -0
  6. package/dist/cli/quant-command.js +345 -0
  7. package/dist/cli/quant-command.js.map +1 -0
  8. package/dist/cli/senpi-commands.d.ts +11 -0
  9. package/dist/cli/senpi-commands.d.ts.map +1 -1
  10. package/dist/cli/senpi-commands.js +49 -4
  11. package/dist/cli/senpi-commands.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +40 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/quant/engine/quantdesk/__init__.py +19 -0
  16. package/dist/quant/engine/quantdesk/__main__.py +281 -0
  17. package/dist/quant/engine/quantdesk/_version.py +24 -0
  18. package/dist/quant/engine/quantdesk/benchmark.py +90 -0
  19. package/dist/quant/engine/quantdesk/book.py +197 -0
  20. package/dist/quant/engine/quantdesk/deep.py +197 -0
  21. package/dist/quant/engine/quantdesk/desk.py +878 -0
  22. package/dist/quant/engine/quantdesk/dsl.py +224 -0
  23. package/dist/quant/engine/quantdesk/followups.py +117 -0
  24. package/dist/quant/engine/quantdesk/hl_api.py +473 -0
  25. package/dist/quant/engine/quantdesk/market.py +290 -0
  26. package/dist/quant/engine/quantdesk/mcp_client.py +158 -0
  27. package/dist/quant/engine/quantdesk/metrics.py +409 -0
  28. package/dist/quant/engine/quantdesk/opportunities.py +88 -0
  29. package/dist/quant/engine/quantdesk/render.py +787 -0
  30. package/dist/quant/engine/quantdesk/roundtrips.py +124 -0
  31. package/dist/quant/engine/quantdesk/score.py +817 -0
  32. package/dist/quant/engine/quantdesk/senpi_history.py +160 -0
  33. package/dist/quant/engine/quantdesk/smart_money.py +354 -0
  34. package/dist/quant/engine/quantdesk/strategy_read.py +292 -0
  35. package/dist/quant/engine/quantdesk/taxonomy.py +55 -0
  36. package/dist/quant/engine/quantdesk/timing.py +164 -0
  37. package/dist/quant/engine/quantdesk/voice.py +42 -0
  38. package/dist/quant/engine-runner.d.ts +133 -0
  39. package/dist/quant/engine-runner.d.ts.map +1 -0
  40. package/dist/quant/engine-runner.js +306 -0
  41. package/dist/quant/engine-runner.js.map +1 -0
  42. package/dist/quant/job.d.ts +125 -0
  43. package/dist/quant/job.d.ts.map +1 -0
  44. package/dist/quant/job.js +184 -0
  45. package/dist/quant/job.js.map +1 -0
  46. package/dist/quant/register.d.ts +53 -0
  47. package/dist/quant/register.d.ts.map +1 -0
  48. package/dist/quant/register.js +253 -0
  49. package/dist/quant/register.js.map +1 -0
  50. package/dist/quant/registry.d.ts +105 -0
  51. package/dist/quant/registry.d.ts.map +1 -0
  52. package/dist/quant/registry.js +248 -0
  53. package/dist/quant/registry.js.map +1 -0
  54. package/dist/quant/types.d.ts +216 -0
  55. package/dist/quant/types.d.ts.map +1 -0
  56. package/dist/quant/types.js +121 -0
  57. package/dist/quant/types.js.map +1 -0
  58. package/dist/skills-manager/manifest.js +1 -1
  59. package/dist/skills-manager/skills-manifest.json +1 -1
  60. package/dist/widgets/index.d.ts +8 -1
  61. package/dist/widgets/index.d.ts.map +1 -1
  62. package/dist/widgets/index.js +1 -0
  63. package/dist/widgets/index.js.map +1 -1
  64. package/dist/widgets/registry.d.ts +8 -0
  65. package/dist/widgets/registry.d.ts.map +1 -1
  66. package/dist/widgets/registry.js +26 -1
  67. package/dist/widgets/registry.js.map +1 -1
  68. package/dist/widgets/tool.d.ts +14 -4
  69. package/dist/widgets/tool.d.ts.map +1 -1
  70. package/dist/widgets/tool.js +36 -23
  71. package/dist/widgets/tool.js.map +1 -1
  72. package/dist/widgets/turn-cap.d.ts +33 -0
  73. package/dist/widgets/turn-cap.d.ts.map +1 -0
  74. package/dist/widgets/turn-cap.js +119 -0
  75. package/dist/widgets/turn-cap.js.map +1 -0
  76. package/dist/widgets/types.d.ts +33 -5
  77. package/dist/widgets/types.d.ts.map +1 -1
  78. package/dist/widgets/types.js +4 -3
  79. package/dist/widgets/types.js.map +1 -1
  80. package/dist/widgets/widgets/fund-user-wallet.d.ts.map +1 -1
  81. package/dist/widgets/widgets/fund-user-wallet.js +1 -0
  82. package/dist/widgets/widgets/fund-user-wallet.js.map +1 -1
  83. package/dist/widgets/widgets/quant-desk-recommendations.d.ts +52 -0
  84. package/dist/widgets/widgets/quant-desk-recommendations.d.ts.map +1 -0
  85. package/dist/widgets/widgets/quant-desk-recommendations.js +278 -0
  86. package/dist/widgets/widgets/quant-desk-recommendations.js.map +1 -0
  87. package/openclaw.plugin.json +12 -2
  88. package/package.json +4 -3
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """Closed round trips from Senpi discovery (`discovery_get_trader_history`) — the PRIMARY source when a
3
+ Senpi token is present. Senpi ingests the whole fill stream, TWAP slices included, so its closed positions
4
+ are complete where the public endpoints are not. Rows are mapped onto the same episode schema the
5
+ fill-based tracker produces, so every downstream module is source-agnostic.
6
+
7
+ Row fields (guide `senpi://guides/trader-closed-positions`): closedOrderId, coin, coinDisplayName, entryPx,
8
+ exitPx, leverage{type,value}, maxLeverage, openTime, closeTime (ms), szi (absolute size), realizedPnl
9
+ (gross of fees), marginUsed, type, totalFills, totalFees (signed). Numbers arrive as strings.
10
+
11
+ `type` is the exchange's own label and is the ONLY place a liquidation is named: "Close Long",
12
+ "Close Short", "Liquidated Cross Long", "Liquidated Isolated Short", "Auto-Deleveraging". A row whose
13
+ opening side discovery could not rebuild arrives with szi, entryPx and openTime all ZERO — common on
14
+ cross-liquidations and HIP-3 `xyz:` assets. Those rows still carry real realized P&L, so they are kept
15
+ and marked `complete=False`/`truncated=True` rather than dropped."""
16
+ # Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
17
+ PAGE = 200
18
+ MAX_PAGES = 25
19
+
20
+
21
+ def _num(v, d=0.0):
22
+ try:
23
+ return float(v)
24
+ except (TypeError, ValueError):
25
+ return d
26
+
27
+
28
+ def _ms(t):
29
+ t = _num(t)
30
+ return t * 1000.0 if t and t < 1e12 else t
31
+
32
+
33
+ def _rows(data):
34
+ if isinstance(data, dict):
35
+ if data.get("success") is False:
36
+ return []
37
+ data = data.get("data", data)
38
+ if isinstance(data, dict):
39
+ for k in ("closedPositions", "closed_positions", "positions", "data"):
40
+ v = data.get(k)
41
+ if isinstance(v, list):
42
+ return v
43
+ return []
44
+ return data if isinstance(data, list) else []
45
+
46
+
47
+ def fetch(client, addr, window_start_ms, meta):
48
+ """Page newest-first until a page ends before the window; returns episode dicts closed in the window.
49
+
50
+ Sets `meta["senpi_history_failed"]` when a page could not be READ — an exception or a
51
+ `success: false` envelope. An empty list means one of two very different things, and the caller
52
+ has to tell them apart: senpi has no closed positions for this wallet in the window (a quiet
53
+ wallet, or one that is not indexed yet), or senpi could not answer. Reporting the second as the
54
+ first tells an indexed reader they are not indexed — and, since the desk offers to flag them to
55
+ the team, promises something about a wallet that is already there.
56
+ """
57
+ out, offset = [], 0
58
+ for _page in range(MAX_PAGES):
59
+ try:
60
+ resp = client.mcp_call("discovery_get_trader_history", trader_address=addr, latest=True, limit=PAGE, offset=offset,
61
+ sort_by="CLOSED_TIME", sort_direction="DESC", timeout=20)
62
+ except Exception as e: # noqa: BLE001
63
+ meta.setdefault("warnings", []).append(f"senpi history page {offset // PAGE} failed: {e}")
64
+ meta["senpi_history_failed"] = True
65
+ # Pages already read are a PREFIX of the history, not the history. Breaking with `out`
66
+ # non-empty shipped a truncated record as a complete one — same source line, same
67
+ # `indexed: True`, totals quietly short. (@0xsarvesh, #718.)
68
+ if out:
69
+ meta["senpi_history_partial"] = True
70
+ break
71
+ if isinstance(resp, dict) and resp.get("success") is False:
72
+ # `_rows` flattens this to [] like any empty page, and it is the shape a degraded
73
+ # discovery service returns most often — so it has to be caught before that.
74
+ meta.setdefault("warnings", []).append(
75
+ f"senpi history page {offset // PAGE} refused: {str((resp.get('error') or {}).get('code') or 'success=false')[:80]}")
76
+ meta["senpi_history_failed"] = True
77
+ break
78
+ rows = _rows(resp)
79
+ if not rows:
80
+ break
81
+ for r in rows:
82
+ e = episode(r)
83
+ if e and e["close_time"] >= window_start_ms:
84
+ out.append(e)
85
+ if _ms(rows[-1].get("closeTime")) < window_start_ms or len(rows) < PAGE:
86
+ break
87
+ offset += PAGE
88
+ else:
89
+ # Fell out of the loop without ever hitting a short page: there is more history than
90
+ # MAX_PAGES covers and what we hold is a PREFIX. This exits NORMALLY, so #733's flag — set
91
+ # only in the except branch — never fired, and a truncated record shipped as complete with
92
+ # indexed=True. (@danielmbirochi, #718.)
93
+ if out:
94
+ meta.setdefault("warnings", []).append(
95
+ f"senpi history hit the {MAX_PAGES}-page ceiling; older closed positions are not included")
96
+ meta["senpi_history_partial"] = True
97
+ return sorted(out, key=lambda e: e["close_time"])
98
+
99
+
100
+ def _direction(r, szi, ent, ext, realized):
101
+ """`szi` on a closed row is the absolute size on the live API (a closed short arrives as "0.2099", not
102
+ "-0.2099"), so the sign is the last resort: the price move against the P&L decides when both are
103
+ non-zero, then the row's `type` ("Close Short" / "Close Long"), then an explicit side field, then szi."""
104
+ if ent and ext and ext != ent and realized:
105
+ return "LONG" if ((ext > ent) == (realized > 0)) else "SHORT"
106
+ typ = str(r.get("type") or "").upper()
107
+ if "SHORT" in typ:
108
+ return "SHORT"
109
+ if "LONG" in typ:
110
+ return "LONG"
111
+ side = str(r.get("direction") or r.get("side") or r.get("positionSide") or "").upper()
112
+ if side in ("LONG", "SHORT"):
113
+ return side
114
+ if side in ("BUY", "B"):
115
+ return "LONG"
116
+ if side in ("SELL", "A", "S"):
117
+ return "SHORT"
118
+ return "LONG" if szi > 0 else "SHORT"
119
+
120
+
121
+ def _liquidated(r):
122
+ """`type` carries the exchange's own words — "Liquidated Cross Long", "Liquidated Isolated Short".
123
+ Matching on "LIQUIDAT" covers all four. `Auto-Deleveraging` is deliberately NOT counted: ADL is the
124
+ venue unwinding a profitable book, not a stop the trader failed to place."""
125
+ return "LIQUIDAT" in str(r.get("type") or "").upper()
126
+
127
+
128
+ def episode(r):
129
+ coin = r.get("coin") or r.get("coinDisplayName")
130
+ close_t = _ms(r.get("closeTime"))
131
+ if not coin or not close_t:
132
+ return None # with no coin and no close there is nothing to place in the window
133
+ szi = _num(r.get("szi"))
134
+ ent, ext = _num(r.get("entryPx")), _num(r.get("exitPx"))
135
+ lev = r.get("leverage") or {}
136
+ open_t = _ms(r.get("openTime"))
137
+ # Discovery cannot always rebuild the OPENING side of a position: a cross-liquidation arrives with
138
+ # szi, entryPx and openTime all zero. Requiring them dropped the whole row — and those rows carry
139
+ # real realized P&L. On one wallet that was $1,137,374 of a $1,496,623 loss, including a $773,802
140
+ # cross-liquidation; venue-wide it is 14% of realized P&L by magnitude. Keep the row so every TOTAL
141
+ # is right, and mark it incomplete so the statistics that genuinely need an open — hold time, entry
142
+ # timing, sizing, the lever grid — skip it exactly as they skip a truncated fill-built episode.
143
+ reconstructed = bool(szi) and bool(open_t)
144
+ if not open_t:
145
+ # never leave this 0: `score._funding_after` reads `open_time + 24h` as a real instant, and an
146
+ # epoch open would match every funding payment before the close.
147
+ open_t = close_t
148
+ size = abs(szi); notional = size * ent
149
+ realized = _num(r.get("realizedPnl")); fees = _num(r.get("totalFees"))
150
+ direction = _direction(r, szi, ent, ext, realized)
151
+ signed = size if direction == "LONG" else -size
152
+ return dict(coin=coin, signed=signed, direction=direction, open_time=open_t, close_time=close_t, last_time=close_t,
153
+ realized=realized, fees=fees, net=realized - fees, volume=size * (ent + ext), taker_volume=0.0, twap_volume=0.0,
154
+ adds=None, partial_closes=0, entry_qty=size, entry_val=notional, exit_qty=size,
155
+ exit_val=size * ext, peak_size=size, peak_notional=notional, liquidated=_liquidated(r),
156
+ truncated=not reconstructed, n_fills=int(_num(r.get("totalFills"), 0)),
157
+ unobserved_qty=0.0, unobserved_notional=0.0, close_observed=True, entry_vwap=ent or None, exit_vwap=ext or None,
158
+ hold_h=(close_t - open_t) / 3.6e6,
159
+ taker_share=None, complete=reconstructed, win=realized > 0, leverage=_num(lev.get("value")) if isinstance(lev, dict) else _num(lev),
160
+ margin_used=_num(r.get("marginUsed")), closed_order_id=r.get("closedOrderId"), source="senpi")
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env python3
2
+ """You vs the whale cohort. Two sources for the cohort, same math (ported from senpi-smart-money):
3
+
4
+ * senpi discovery — the ALL_TIME realized-PnL ranking (`discovery_get_top_traders`), members with ≥ $1M
5
+ realized, their live books from `discovery_get_trader_state` with position ages (entry times →
6
+ "WITH — BUT LATE"). Needs a Senpi token.
7
+ * public fallback — the largest profitable accounts on Hyperliquid's public leaderboard, their live books
8
+ from `clearinghouseState` (no entry times → WITH / AGAINST only).
9
+
10
+ Per coin the cohort's bias is net/gross signed notional in [-1, +1]. The you-vs-whale-median table reads
11
+ `references/benchmark.json`, computed by `benchmark.py` over the public cohort with this skill's own engine."""
12
+ # Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
13
+ import statistics
14
+
15
+ SMART_MIN_REALIZED = 1_000_000
16
+ PAGE_SIZE = 1000
17
+ MAX_PAGES = 6
18
+ SAMPLE_CAP = 150
19
+ STATE_BATCH = 50
20
+ MIN_MEMBERS = 3
21
+ LEAN = 0.2 # |bias| below this = the cohort is split
22
+
23
+ LATE_H = 4.0 # entering this much after the cohort's median entry = "late"
24
+ RECENT_H = 14 * 24.0 # a cohort entry older than this is a hold, not a move: no lag is read against it
25
+ def _side(bias):
26
+ """LONG / SHORT / None. `bias > 0 else "SHORT"` made a bias of exactly 0.0 — a perfectly split
27
+ cohort, or one with nothing to read at all (`bias` is set to 0.0 when gross is 0) — render as a
28
+ SHORT cohort. Zero is the absence of a lean, not a lean the other way.
29
+ (@danielmbirochi, #718, round 2.)"""
30
+ return None if not bias else ("LONG" if bias > 0 else "SHORT")
31
+
32
+
33
+ def _ok(resp):
34
+ if isinstance(resp, dict):
35
+ if resp.get("success") is False:
36
+ return None
37
+ return resp.get("data", resp)
38
+ return resp
39
+
40
+
41
+ def _num(v):
42
+ try:
43
+ return float(v)
44
+ except (TypeError, ValueError):
45
+ return None
46
+
47
+
48
+ def _f(d, *keys, default=0.0):
49
+ if isinstance(d, dict):
50
+ for k in keys:
51
+ if k in d and d[k] is not None:
52
+ n = _num(d[k])
53
+ if n is not None:
54
+ return n
55
+ return default
56
+
57
+
58
+ def _field(d, *names, default=None):
59
+ if isinstance(d, dict):
60
+ for n in names:
61
+ if n in d and d[n] is not None:
62
+ return d[n]
63
+ return default
64
+
65
+
66
+ def _traders_of(data):
67
+ if isinstance(data, list):
68
+ return data
69
+ if isinstance(data, dict):
70
+ for k in ("traders", "data", "results"):
71
+ v = data.get(k)
72
+ if isinstance(v, list):
73
+ return v
74
+ return []
75
+
76
+
77
+ def _realized(t):
78
+ return _f(t, "realizedProfitAndLoss", "realized_profit_and_loss", "profit_and_loss_realized", "realizedPnl", "realized_pnl", default=0.0)
79
+
80
+
81
+ def _signed_notional(p):
82
+ szi = _f(p, "szi", "size")
83
+ val = _f(p, "positionValue", "notional", "position_value")
84
+ if val <= 0:
85
+ val = abs(szi) * _f(p, "entryPx", "markPx", "entry_price")
86
+ return (1.0 if szi > 0 else (-1.0 if szi < 0 else 0.0)) * abs(val)
87
+
88
+
89
+ def _empty():
90
+ return {"net": 0.0, "gross": 0.0, "n_long": 0, "n_short": 0, "entries_long": [], "entries_short": []}
91
+
92
+
93
+ def _add(per, coin, sn, entry_ms=None):
94
+ d = per.setdefault(coin, _empty())
95
+ d["net"] += sn; d["gross"] += abs(sn); d["n_long" if sn > 0 else "n_short"] += 1
96
+ if entry_ms:
97
+ d["entries_long" if sn > 0 else "entries_short"].append(entry_ms)
98
+
99
+
100
+ def _finish(per):
101
+ for d in per.values():
102
+ d["bias"] = round(d["net"] / d["gross"], 3) if d["gross"] > 0 else 0.0
103
+ d["members"] = d["n_long"] + d["n_short"]
104
+ return per
105
+
106
+
107
+ # ---------------------------------------------------------------- senpi source
108
+ def senpi_cohort(client, meta):
109
+ smart, seen = [], set()
110
+ for page in range(MAX_PAGES):
111
+ try:
112
+ resp = client.mcp_call("discovery_get_top_traders", time_frame="ALL_TIME", sort_by="PROFIT_AND_LOSS_REALIZED",
113
+ open_position_filter=False, limit=PAGE_SIZE, offset=page * PAGE_SIZE, timeout=20)
114
+ except Exception as e: # noqa: BLE001
115
+ meta.setdefault("warnings", []).append(f"top_traders page {page} failed: {e}")
116
+ break
117
+ rows = _traders_of(_ok(resp))
118
+ if not rows:
119
+ break
120
+ page_top = None
121
+ for t in rows:
122
+ if not isinstance(t, dict):
123
+ continue
124
+ addr = str(_field(t, "address", "trader_address", "wallet", default="")).lower()
125
+ if not addr or addr in seen:
126
+ continue
127
+ rp = _realized(t)
128
+ page_top = rp if page_top is None else max(page_top, rp)
129
+ if rp >= SMART_MIN_REALIZED and len(smart) < SAMPLE_CAP:
130
+ smart.append(addr); seen.add(addr)
131
+ if len(smart) >= SAMPLE_CAP or (page_top is not None and page_top < SMART_MIN_REALIZED):
132
+ break
133
+ return smart
134
+
135
+
136
+ def senpi_positions(client, addrs, meta):
137
+ per = {}
138
+ for i in range(0, len(addrs), STATE_BATCH):
139
+ batch = addrs[i:i + STATE_BATCH]
140
+ try:
141
+ resp = client.mcp_call("discovery_get_trader_state", trader_addresses=batch, include_position_age=True, timeout=25)
142
+ except Exception as e: # noqa: BLE001
143
+ meta.setdefault("warnings", []).append(f"trader_state batch failed: {e}")
144
+ continue
145
+ for t in _traders_of(_ok(resp)):
146
+ for p in (t.get("openPositions") or t.get("open_positions") or []):
147
+ if not isinstance(p, dict):
148
+ continue
149
+ coin = p.get("coin") or p.get("asset")
150
+ sn = _signed_notional(p) if coin else 0.0
151
+ if not coin or sn == 0:
152
+ continue
153
+ st = _f(p, "startTime", "start_time", default=0.0)
154
+ entry_ms = (st * 1000.0 if st < 1e12 else st) if st else None # documented as Unix seconds
155
+ _add(per, coin, sn, entry_ms)
156
+ return _finish(per)
157
+
158
+
159
+ # ---------------------------------------------------------------- public source
160
+ def public_positions(states):
161
+ per = {}
162
+ for cs in (states or {}).values():
163
+ for ap in (cs or {}).get("assetPositions") or []:
164
+ p = ap.get("position") or {}
165
+ sn = _signed_notional(p)
166
+ if p.get("coin") and sn:
167
+ _add(per, p["coin"], sn)
168
+ return _finish(per)
169
+
170
+
171
+ # ---------------------------------------------------------------- the reads
172
+ def compare(per, book, opened_episodes, ages=None, now_ms=None):
173
+ """One row per open position: your side vs the cohort's bias on that coin, and the read. Your entry
174
+ time comes from Senpi's position age when present; from fills only when the open was actually observed."""
175
+ opens = {e["coin"]: e["open_time"] for e in opened_episodes or [] if not e.get("truncated") and not e.get("unobserved_qty")}
176
+ opens.update({c: t for c, t in (ages or {}).items() if t})
177
+ rows, lags, lag_coins = [], [], []
178
+ for p in book["positions"]:
179
+ d = per.get(p["coin"])
180
+ bias = d["bias"] if d else None
181
+ members = d["members"] if d else 0
182
+ lag = None
183
+ if not d or members < MIN_MEMBERS:
184
+ read = "NO COHORT VIEW"
185
+ elif abs(bias) < LEAN:
186
+ read = "COHORT SPLIT"
187
+ elif _side(bias) == p["side"]:
188
+ read = "WITH"
189
+ ent = d["entries_long" if p["side"] == "LONG" else "entries_short"]
190
+ if ent and p["coin"] in opens:
191
+ med_entry = statistics.median(ent); age_h = (now_ms - med_entry) / 3.6e6 if now_ms else None
192
+ if age_h is not None and age_h > RECENT_H:
193
+ read = f"WITH — they've held it {age_h / 24:.0f}d"
194
+ else:
195
+ lag = (opens[p["coin"]] - med_entry) / 3.6e6
196
+ lags.append(lag); lag_coins.append(p["coin"])
197
+ read = f"WITH — BUT LATE (+{lag:.0f}h)" if lag > LATE_H else ("WITH — AHEAD" if lag < -LATE_H else "WITH")
198
+ else:
199
+ read = "AGAINST SMART MONEY"
200
+ if not d:
201
+ cohort = "no whale holds it"
202
+ elif members < MIN_MEMBERS:
203
+ cohort = (f"{_side(bias)} · {members} wallet{'s' if members != 1 else ''} (thin)"
204
+ if _side(bias) else f"SPLIT · {members} wallet{'s' if members != 1 else ''} (thin)")
205
+ elif abs(bias) < LEAN:
206
+ cohort = f"SPLIT · {members} wallets"
207
+ else:
208
+ cohort = f"{_side(bias)} · {members} wallets ({bias:+.2f})"
209
+ rows.append(dict(coin=p["coin"], you=f"{p['side']} {p['leverage']}x" if p.get("leverage") else p["side"], cohort=cohort,
210
+ bias=bias, members=members, read=read, lag_h=lag))
211
+ return dict(rows=rows, against=[r["coin"] for r in rows if r["read"].startswith("AGAINST")],
212
+ entry_lag_h=statistics.median(lags) if lags else None, lag_coins=lag_coins)
213
+
214
+
215
+ BENCH_ROWS = (("Median hold — winners", "hold_winners_h", "h"), ("Median hold — losers", "hold_losers_h", "h"), ("Losers held ÷ winners held", "hold_ratio", "x"),
216
+ ("Costs ÷ gross income", "cost_ratio", "%"), ("Taker share of volume", "taker_share", "%"), ("Win rate", "win_rate", "%"),
217
+ ("Profit factor", "profit_factor", "x"), ("Avg winner ÷ avg loser", "payoff_ratio", "x"))
218
+
219
+
220
+ def benchmark_table(tr, bench):
221
+ rows = []
222
+ for label, key, unit in BENCH_ROWS:
223
+ you, whale = tr.get(key), (bench or {}).get(key)
224
+ rows.append(dict(metric=label, you=you, whale=whale, unit=unit,
225
+ better="lower" if key in ("hold_losers_h", "hold_ratio", "cost_ratio", "taker_share") else "higher"))
226
+ return rows
227
+
228
+
229
+ # ================================================================ v2: two cohorts, entry ages, the whole book
230
+ import collections # noqa: E402
231
+
232
+ import taxonomy # noqa: E402
233
+
234
+ HOT_N = 100
235
+ PROVEN_N = 100
236
+
237
+
238
+ def hot_cohort(client, meta, n=HOT_N):
239
+ """The most profitable traders of the last 30 days that hold positions now — a hot streak, not a record."""
240
+ try:
241
+ resp = client.mcp_call("discovery_get_top_traders", time_frame="MONTHLY", sort_by="PROFIT_AND_LOSS", open_position_filter=True, limit=n, offset=0, timeout=20)
242
+ except Exception as e: # noqa: BLE001
243
+ meta.setdefault("warnings", []).append(f"hot cohort failed: {e}")
244
+ return []
245
+ out = []
246
+ for t in _traders_of(_ok(resp)):
247
+ a = str(_field(t, "address", "trader_address", "wallet", default="")).lower()
248
+ if a:
249
+ out.append(a)
250
+ return out
251
+
252
+
253
+ def proven_cohort(client, meta, n=PROVEN_N):
254
+ """Top traders by ALL-TIME realized PnL with ≥ $1M realized — a demonstrated record."""
255
+ global SAMPLE_CAP
256
+ keep = SAMPLE_CAP
257
+ SAMPLE_CAP = n
258
+ try:
259
+ return senpi_cohort(client, meta)
260
+ finally:
261
+ SAMPLE_CAP = keep
262
+
263
+
264
+ def books(client, addrs, meta, progress=None, label=""):
265
+ """Per-wallet live books (coin, signed notional, entry ms) for a cohort, batched."""
266
+ out = []
267
+ for i in range(0, len(addrs), STATE_BATCH):
268
+ batch = addrs[i:i + STATE_BATCH]
269
+ try:
270
+ resp = client.mcp_call("discovery_get_trader_state", trader_addresses=batch, include_position_age=True, timeout=25)
271
+ except Exception as e: # noqa: BLE001
272
+ meta.setdefault("warnings", []).append(f"trader_state batch failed: {e}")
273
+ continue
274
+ if progress:
275
+ progress(f"[quant-desk] · senpi-smart-money: {label}{min(i + STATE_BATCH, len(addrs))} of {len(addrs)} wallets read …")
276
+ for t in _traders_of(_ok(resp)):
277
+ rows = []
278
+ for p in (t.get("openPositions") or t.get("open_positions") or []):
279
+ if not isinstance(p, dict):
280
+ continue
281
+ coin = p.get("coin") or p.get("asset"); sn = _signed_notional(p) if coin else 0.0
282
+ if not coin or not sn:
283
+ continue
284
+ st = _f(p, "startTime", "start_time", default=0.0)
285
+ rows.append((coin, sn, (st * 1000.0 if st < 1e12 else st) if st else None))
286
+ out.append(dict(address=str(_field(t, "address", "trader_address", "wallet", default="")).lower(), positions=rows))
287
+ return out
288
+
289
+
290
+ def public_books(states):
291
+ out = []
292
+ for a, cs in (states or {}).items():
293
+ rows = []
294
+ for ap in (cs or {}).get("assetPositions") or []:
295
+ p = ap.get("position") or {}; sn = _signed_notional(p)
296
+ if p.get("coin") and sn:
297
+ rows.append((p["coin"], sn, None))
298
+ if rows:
299
+ out.append(dict(address=a, positions=rows))
300
+ return out
301
+
302
+
303
+ def per_from_books(bks):
304
+ per = {}
305
+ for b in bks:
306
+ for coin, sn, entry_ms in b["positions"]:
307
+ _add(per, coin, sn, entry_ms)
308
+ return _finish(per)
309
+
310
+
311
+ def class_tilt(bks, majors, large):
312
+ """Net ÷ gross signed notional per asset class across the cohort's books, plus headcount per class-side."""
313
+ net = collections.defaultdict(float); gross = collections.defaultdict(float); heads = collections.Counter()
314
+ for b in bks:
315
+ for coin, sn, _ in b["positions"]:
316
+ cls = taxonomy.classify(coin, majors, large)
317
+ net[cls] += sn; gross[cls] += abs(sn); heads[(cls, "LONG" if sn > 0 else "SHORT")] += 1
318
+ tot = sum(gross.values()) or 1.0
319
+ return {cls: dict(label=taxonomy.label(cls), bias=(net[cls] / gross[cls]) if gross[cls] else 0.0, weight=gross[cls] / tot,
320
+ long=heads[(cls, "LONG")], short=heads[(cls, "SHORT")]) for cls in gross}
321
+
322
+
323
+ def user_tilt(book, majors, large):
324
+ net = collections.defaultdict(float); gross = collections.defaultdict(float)
325
+ for p in book["positions"]:
326
+ cls = taxonomy.classify(p["coin"], majors, large); sn = p["notional"] * (1 if p["side"] == "LONG" else -1)
327
+ net[cls] += sn; gross[cls] += abs(sn)
328
+ tot = sum(gross.values()) or 1.0
329
+ return {cls: dict(label=taxonomy.label(cls), bias=(net[cls] / gross[cls]) if gross[cls] else 0.0, weight=gross[cls] / tot) for cls in gross}
330
+
331
+
332
+ def cohort_view(name, bks, book, opened, majors, large, ages=None, now_ms=None):
333
+ """Everything the desk says about one cohort: per-position reads (with ages), class tilt vs yours,
334
+ coins they hold that you don't (by headcount), coins you hold that none of them touch."""
335
+ per = per_from_books(bks)
336
+ cmp_ = compare(per, book, opened, ages, now_ms)
337
+ tilt = class_tilt(bks, majors, large); yours = user_tilt(book, majors, large)
338
+ mine = {p["coin"] for p in book["positions"]}
339
+ theirs = sorted(((d["members"], coin, d) for coin, d in per.items() if coin not in mine and d["members"] >= MIN_MEMBERS and abs(d["bias"]) >= LEAN), reverse=True)
340
+ orphans = [c for c in mine if c not in per]
341
+ # book-level agreement: weight-averaged sign agreement over the classes you hold
342
+ agree = 0.0; wsum = 0.0
343
+ for cls, y in yours.items():
344
+ t = tilt.get(cls)
345
+ # A class with no lean on either side contributed a full-weight AGREEMENT, because
346
+ # `False == False` is True — two books with no opinion were scored as being of one mind.
347
+ if t and t["weight"] > 0 and _side(y["bias"]) and _side(t["bias"]):
348
+ agree += y["weight"] * (1 if _side(y["bias"]) == _side(t["bias"]) else -1) * min(1.0, abs(t["bias"]) / LEAN)
349
+ wsum += y["weight"]
350
+ return dict(name=name, wallets=len(bks), coins=len(per), rows=cmp_["rows"], against=cmp_["against"], entry_lag_h=cmp_["entry_lag_h"], lag_coins=cmp_.get("lag_coins") or [],
351
+ tilt=sorted(tilt.values(), key=lambda t: -t["weight"])[:6], yours=sorted(yours.values(), key=lambda t: -t["weight"]),
352
+ agreement=(agree / wsum) if wsum else None,
353
+ they_hold=[dict(coin=c, members=m, bias=d["bias"], side=_side(d["bias"]), n_long=d["n_long"], n_short=d["n_short"]) for m, c, d in theirs[:8]],
354
+ you_alone=orphans)