@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.
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +55 -0
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/quant-command.d.ts +128 -0
- package/dist/cli/quant-command.d.ts.map +1 -0
- package/dist/cli/quant-command.js +345 -0
- package/dist/cli/quant-command.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts +11 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +49 -4
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -1
- package/dist/quant/engine/quantdesk/__init__.py +19 -0
- package/dist/quant/engine/quantdesk/__main__.py +281 -0
- package/dist/quant/engine/quantdesk/_version.py +24 -0
- package/dist/quant/engine/quantdesk/benchmark.py +90 -0
- package/dist/quant/engine/quantdesk/book.py +197 -0
- package/dist/quant/engine/quantdesk/deep.py +197 -0
- package/dist/quant/engine/quantdesk/desk.py +878 -0
- package/dist/quant/engine/quantdesk/dsl.py +224 -0
- package/dist/quant/engine/quantdesk/followups.py +117 -0
- package/dist/quant/engine/quantdesk/hl_api.py +473 -0
- package/dist/quant/engine/quantdesk/market.py +290 -0
- package/dist/quant/engine/quantdesk/mcp_client.py +158 -0
- package/dist/quant/engine/quantdesk/metrics.py +409 -0
- package/dist/quant/engine/quantdesk/opportunities.py +88 -0
- package/dist/quant/engine/quantdesk/render.py +787 -0
- package/dist/quant/engine/quantdesk/roundtrips.py +124 -0
- package/dist/quant/engine/quantdesk/score.py +817 -0
- package/dist/quant/engine/quantdesk/senpi_history.py +160 -0
- package/dist/quant/engine/quantdesk/smart_money.py +354 -0
- package/dist/quant/engine/quantdesk/strategy_read.py +292 -0
- package/dist/quant/engine/quantdesk/taxonomy.py +55 -0
- package/dist/quant/engine/quantdesk/timing.py +164 -0
- package/dist/quant/engine/quantdesk/voice.py +42 -0
- package/dist/quant/engine-runner.d.ts +133 -0
- package/dist/quant/engine-runner.d.ts.map +1 -0
- package/dist/quant/engine-runner.js +306 -0
- package/dist/quant/engine-runner.js.map +1 -0
- package/dist/quant/job.d.ts +125 -0
- package/dist/quant/job.d.ts.map +1 -0
- package/dist/quant/job.js +184 -0
- package/dist/quant/job.js.map +1 -0
- package/dist/quant/register.d.ts +53 -0
- package/dist/quant/register.d.ts.map +1 -0
- package/dist/quant/register.js +253 -0
- package/dist/quant/register.js.map +1 -0
- package/dist/quant/registry.d.ts +105 -0
- package/dist/quant/registry.d.ts.map +1 -0
- package/dist/quant/registry.js +248 -0
- package/dist/quant/registry.js.map +1 -0
- package/dist/quant/types.d.ts +216 -0
- package/dist/quant/types.d.ts.map +1 -0
- package/dist/quant/types.js +121 -0
- package/dist/quant/types.js.map +1 -0
- package/dist/skills-manager/manifest.js +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -1
- package/dist/widgets/index.d.ts +8 -1
- package/dist/widgets/index.d.ts.map +1 -1
- package/dist/widgets/index.js +1 -0
- package/dist/widgets/index.js.map +1 -1
- package/dist/widgets/registry.d.ts +8 -0
- package/dist/widgets/registry.d.ts.map +1 -1
- package/dist/widgets/registry.js +26 -1
- package/dist/widgets/registry.js.map +1 -1
- package/dist/widgets/tool.d.ts +14 -4
- package/dist/widgets/tool.d.ts.map +1 -1
- package/dist/widgets/tool.js +36 -23
- package/dist/widgets/tool.js.map +1 -1
- package/dist/widgets/turn-cap.d.ts +33 -0
- package/dist/widgets/turn-cap.d.ts.map +1 -0
- package/dist/widgets/turn-cap.js +119 -0
- package/dist/widgets/turn-cap.js.map +1 -0
- package/dist/widgets/types.d.ts +33 -5
- package/dist/widgets/types.d.ts.map +1 -1
- package/dist/widgets/types.js +4 -3
- package/dist/widgets/types.js.map +1 -1
- package/dist/widgets/widgets/fund-user-wallet.d.ts.map +1 -1
- package/dist/widgets/widgets/fund-user-wallet.js +1 -0
- package/dist/widgets/widgets/fund-user-wallet.js.map +1 -1
- package/dist/widgets/widgets/quant-desk-recommendations.d.ts +52 -0
- package/dist/widgets/widgets/quant-desk-recommendations.d.ts.map +1 -0
- package/dist/widgets/widgets/quant-desk-recommendations.js +278 -0
- package/dist/widgets/widgets/quant-desk-recommendations.js.map +1 -0
- package/openclaw.plugin.json +12 -2
- package/package.json +4 -3
|
@@ -0,0 +1,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)
|