@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716233548
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/composer-commands.d.ts +37 -0
- package/dist/cli/composer-commands.d.ts.map +1 -0
- package/dist/cli/composer-commands.js +396 -0
- package/dist/cli/composer-commands.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +2 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/validate-runtime-config.mjs +190 -0
- package/dist/composer/AUTHORING.md +435 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +59 -0
- package/dist/composer/authoring/answers.py +297 -0
- package/dist/composer/authoring/archetypes/__init__.py +26 -0
- package/dist/composer/authoring/archetypes/_common.py +410 -0
- package/dist/composer/authoring/archetypes/breakout.py +134 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +76 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
- package/dist/composer/authoring/builder.py +191 -0
- package/dist/composer/authoring/emit.py +264 -0
- package/dist/composer/authoring/gaps.py +115 -0
- package/dist/composer/authoring/generate.py +131 -0
- package/dist/composer/authoring/presets.py +168 -0
- package/dist/composer/authoring/runtime_emit.py +266 -0
- package/dist/composer/authoring/strategy_manifest.py +94 -0
- package/dist/composer/cli.py +487 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +739 -0
- package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
- package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
- package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
- package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
- package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
- package/dist/composer/generated/README.md +22 -0
- package/dist/composer/generated/catalog.md +118 -0
- package/dist/composer/generated/describe.md +1168 -0
- package/dist/composer/generated/graph.schema.json +1946 -0
- package/dist/composer/primitives/__init__.py +320 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +363 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +326 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +979 -0
- package/dist/composer/primitives/stateful.py +254 -0
- package/dist/composer/pure_fn/__init__.py +34 -0
- package/dist/composer/pure_fn/checks.py +39 -0
- package/dist/composer/pure_fn/models.py +177 -0
- package/dist/composer/pure_fn/purity.py +311 -0
- package/dist/composer/pure_fn/runtests.py +155 -0
- package/dist/composer/registry/__init__.py +40 -0
- package/dist/composer/registry/generate.py +425 -0
- package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
- package/dist/composer/registry/mcp_closure.py +311 -0
- package/dist/composer/registry/models.py +417 -0
- package/dist/composer/registry/nodes.py +1297 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/scaffold_path.py +118 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +34 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +227 -0
- package/dist/composer/validator/graph_checks.py +443 -0
- package/dist/composer/validator/loader.py +110 -0
- package/dist/composer/validator/schema_checks.py +147 -0
- package/dist/composer/validator/validate.py +86 -0
- package/dist/composer/vendoring.py +103 -0
- package/dist/composer/verbs/__init__.py +49 -0
- package/dist/composer/verbs/_box.py +308 -0
- package/dist/composer/verbs/check.py +254 -0
- package/dist/composer/verbs/close.py +494 -0
- package/dist/composer/verbs/deploy.py +279 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/fund.py +498 -0
- package/dist/composer/verbs/install.py +586 -0
- package/dist/composer/verbs/runtime_validate.py +422 -0
- package/dist/composer/verbs/smoke.py +134 -0
- package/dist/composer/verbs/status.py +383 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -9
- package/dist/index.js.map +1 -1
- package/dist/runtime/load-runtime-config.d.ts +17 -0
- package/dist/runtime/load-runtime-config.d.ts.map +1 -1
- package/dist/runtime/load-runtime-config.js +2 -2
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime-api/config.d.ts +2 -2
- package/dist/runtime-api/config.js +4 -4
- package/dist/runtime-api/config.js.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
- package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.js +5 -14
- package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +2 -8
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/skills-manager/manifest.d.ts.map +1 -1
- package/dist/skills-manager/manifest.js +1 -3
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -3
- package/package.json +4 -2
- package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
- package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
- package/dist/runtime-api/intake/ingest-mutex.js +0 -18
- package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
"""sources — pure normalizers for the raw MCP responses the data-source nodes read.
|
|
2
|
+
|
|
3
|
+
Split of concerns (slice-3 golden design): the ACTUAL MCP read (``ctx.senpi_mcp.
|
|
4
|
+
call_tool``) lives in the thin ``adapter.McpSourceAdapter``; these functions are the
|
|
5
|
+
PURE part — they take the raw response(s) the adapter fetched and reduce them to the
|
|
6
|
+
node's typed output ports. That split is what lets the golden tests stub source data
|
|
7
|
+
at the adapter/MCP boundary and still exercise the real normalization.
|
|
8
|
+
|
|
9
|
+
Response shapes and reductions are ported from the corpus originals under
|
|
10
|
+
``/Users/duncan/Senpi/skills/strategies/{stag,hawk}/main/scanners/scan.py``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ._candles import _f
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def candles_fetch(raw_by_asset: dict, *, intervals: list) -> dict:
|
|
19
|
+
"""Normalize per-asset ``market_get_asset_data`` responses into the candles port.
|
|
20
|
+
|
|
21
|
+
Output: ``{asset: {interval: [candle_dict, ...]}}`` (the ``candles`` port type).
|
|
22
|
+
Ported from stag ``_fetch_candles`` / hawk ``_asset_data``: unwrap ``data`` then
|
|
23
|
+
``candles[<interval>]``. A missing / falsy / success:false response drops the asset.
|
|
24
|
+
"""
|
|
25
|
+
out: dict = {}
|
|
26
|
+
for asset in sorted(raw_by_asset):
|
|
27
|
+
raw = raw_by_asset[asset]
|
|
28
|
+
if not raw:
|
|
29
|
+
continue
|
|
30
|
+
if isinstance(raw, dict) and raw.get("success") is False:
|
|
31
|
+
continue
|
|
32
|
+
block = raw.get("data", raw) if isinstance(raw, dict) else {}
|
|
33
|
+
if not isinstance(block, dict):
|
|
34
|
+
continue
|
|
35
|
+
cands = block.get("candles", {}) or {}
|
|
36
|
+
if not isinstance(cands, dict):
|
|
37
|
+
continue
|
|
38
|
+
entry = {iv: (cands.get(iv, []) or []) for iv in intervals}
|
|
39
|
+
# Carry asset_context (markPx/prevDayPx) alongside the candles so own_return_24h
|
|
40
|
+
# can derive a 24h return without a second read (thesis-fund). Indicators ignore
|
|
41
|
+
# the extra key (they read candles[asset][interval]); a graph without asset_context
|
|
42
|
+
# (stag/hawk/bison fixtures) is byte-unchanged since the key is simply absent.
|
|
43
|
+
ac = block.get("asset_context")
|
|
44
|
+
if isinstance(ac, dict) and ac:
|
|
45
|
+
entry["asset_context"] = ac
|
|
46
|
+
out[asset] = entry
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def instrument_list(raw) -> list:
|
|
51
|
+
"""Normalize ``market_list_instruments`` into a list of instrument RECORDS. Port:
|
|
52
|
+
instruments (asset_list). Ported from magpie ``_fetch_ipop_universe`` + thesis-fund
|
|
53
|
+
``_get_universe_meta``.
|
|
54
|
+
|
|
55
|
+
Each record: ``{name, funding (|abs|), max_leverage, is_delisted, day_volume}``. The
|
|
56
|
+
records (not bare names) are the ``asset_list`` value flowing into universe_discover /
|
|
57
|
+
classifier_predicate so those predicates can gate on venue fields; universe_discover
|
|
58
|
+
projects back to bare names for the tradeable universe. Sorted by name for determinism.
|
|
59
|
+
"""
|
|
60
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else raw
|
|
61
|
+
insts = data.get("instruments", data) if isinstance(data, dict) else data
|
|
62
|
+
if isinstance(insts, dict):
|
|
63
|
+
insts = insts.get("instruments", [])
|
|
64
|
+
if not isinstance(insts, list):
|
|
65
|
+
return []
|
|
66
|
+
out: list = []
|
|
67
|
+
for inst in insts:
|
|
68
|
+
if not isinstance(inst, dict):
|
|
69
|
+
continue
|
|
70
|
+
ctxb = inst.get("context", {}) if isinstance(inst.get("context"), dict) else {}
|
|
71
|
+
name = inst.get("name") or ctxb.get("coin")
|
|
72
|
+
if not name or not isinstance(name, str):
|
|
73
|
+
continue
|
|
74
|
+
out.append({
|
|
75
|
+
"name": name,
|
|
76
|
+
"funding": abs(_f(ctxb.get("funding", 0))),
|
|
77
|
+
"max_leverage": _f(inst.get("max_leverage", inst.get("maxLeverage", 0))),
|
|
78
|
+
"is_delisted": bool(inst.get("is_delisted", False)),
|
|
79
|
+
"day_volume": _f(ctxb.get("dayNtlVlm", 0)),
|
|
80
|
+
})
|
|
81
|
+
return sorted(out, key=lambda r: r["name"])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def universe_discover(instruments: list, *, min_day_volume: float,
|
|
85
|
+
max_leverage_cap: float, name_prefix: str) -> list:
|
|
86
|
+
"""Derive the tradeable universe (bare NAMES) by filtering instrument records. Port:
|
|
87
|
+
universe (asset_list). Ported from magpie ``ipop_passes_universe`` (the prefix /
|
|
88
|
+
leverage / min-volume portion); the funding portion lives in classifier_predicate, so a
|
|
89
|
+
faithful magpie discovery chains classifier_predicate -> universe_discover.
|
|
90
|
+
|
|
91
|
+
Generalizes thesis-fund's identity "is this on the live board" check: with the default
|
|
92
|
+
params (no prefix, cap 5, min-volume 0) a plain-name list passes through unchanged.
|
|
93
|
+
"""
|
|
94
|
+
out: list = []
|
|
95
|
+
for inst in instruments or []:
|
|
96
|
+
rec = inst if isinstance(inst, dict) else {"name": inst}
|
|
97
|
+
name = rec.get("name")
|
|
98
|
+
if not isinstance(name, str):
|
|
99
|
+
continue
|
|
100
|
+
if name_prefix and not name.startswith(name_prefix):
|
|
101
|
+
continue
|
|
102
|
+
if rec.get("is_delisted"):
|
|
103
|
+
continue
|
|
104
|
+
lev = _f(rec.get("max_leverage", 0))
|
|
105
|
+
if lev and lev > max_leverage_cap:
|
|
106
|
+
continue
|
|
107
|
+
if _f(rec.get("day_volume", 0)) < min_day_volume:
|
|
108
|
+
continue
|
|
109
|
+
out.append(name)
|
|
110
|
+
return sorted(out)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def own_return_24h(candles: dict) -> dict:
|
|
114
|
+
"""Per-asset 24h percent return from the asset_context carried on candles. Port:
|
|
115
|
+
return_24h (feature_map). Ported from thesis-fund ``_asset_market`` ret_24h:
|
|
116
|
+
``(markPx - prevDayPx) / prevDayPx * 100`` when both are positive; else omitted.
|
|
117
|
+
"""
|
|
118
|
+
out: dict = {}
|
|
119
|
+
for asset in sorted(candles):
|
|
120
|
+
block = candles[asset] if isinstance(candles[asset], dict) else {}
|
|
121
|
+
ac = block.get("asset_context")
|
|
122
|
+
if not isinstance(ac, dict):
|
|
123
|
+
continue
|
|
124
|
+
mark = _f(ac.get("markPx", 0))
|
|
125
|
+
prev = _f(ac.get("prevDayPx", 0))
|
|
126
|
+
if prev > 0 and mark > 0:
|
|
127
|
+
out[asset] = ((mark - prev) / prev) * 100.0
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def clearinghouse_state(raw) -> tuple[list, float]:
|
|
132
|
+
"""Reduce a ``strategy_get_clearinghouse_state`` response to (held, equity).
|
|
133
|
+
|
|
134
|
+
Ports: held (asset_list), equity (scalar). Ported from hawk ``_get_account`` +
|
|
135
|
+
stag ``_held_assets``: dual-DEX max() account value across main/xyz (never sum ->
|
|
136
|
+
no double-count of the shared cross-margin balance); held = coins with a non-zero
|
|
137
|
+
``szi`` in either sub-DEX. Includes the read-sanity guard: margin/notional in use
|
|
138
|
+
but an EMPTY positions list is a corrupt read -> (held=[], equity=0.0).
|
|
139
|
+
"""
|
|
140
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else {}
|
|
141
|
+
if not isinstance(data, dict):
|
|
142
|
+
return [], 0.0
|
|
143
|
+
|
|
144
|
+
held: set = set()
|
|
145
|
+
equity = 0.0
|
|
146
|
+
for section in ("main", "xyz"):
|
|
147
|
+
s = data.get(section, {})
|
|
148
|
+
if not isinstance(s, dict):
|
|
149
|
+
continue
|
|
150
|
+
ms = s.get("marginSummary", {}) if isinstance(s.get("marginSummary"), dict) else {}
|
|
151
|
+
equity = max(equity, _f(ms.get("accountValue", 0)))
|
|
152
|
+
for ap in s.get("assetPositions", []) or []:
|
|
153
|
+
pos = ap.get("position", ap) if isinstance(ap, dict) else {}
|
|
154
|
+
if _f(pos.get("szi", 0)) != 0:
|
|
155
|
+
coin = str(pos.get("coin", "")).upper()
|
|
156
|
+
if coin:
|
|
157
|
+
held.add(coin)
|
|
158
|
+
|
|
159
|
+
in_use = 0.0
|
|
160
|
+
for section in ("main", "xyz"):
|
|
161
|
+
s = data.get(section, {}) if isinstance(data, dict) else {}
|
|
162
|
+
ms = s.get("marginSummary", {}) if isinstance(s, dict) else {}
|
|
163
|
+
if not isinstance(ms, dict):
|
|
164
|
+
continue
|
|
165
|
+
in_use = max(in_use, _f(ms.get("totalMarginUsed", 0)), abs(_f(ms.get("totalNtlPos", 0))))
|
|
166
|
+
if in_use > 1.0 and not held:
|
|
167
|
+
return [], 0.0
|
|
168
|
+
|
|
169
|
+
return sorted(held), equity
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def sm_lean(raw, *, assets: list, agree_threshold: float) -> dict:
|
|
173
|
+
"""Reduce ``leaderboard_get_markets`` to a per-asset smart-money lean. Port: lean.
|
|
174
|
+
|
|
175
|
+
Ported from stag/hawk ``_sm_direction`` / ``_get_sm_direction``: for each asset,
|
|
176
|
+
long_ratio = long_pct / (long_pct + short_pct) * 100. Output feature value is the
|
|
177
|
+
long_ratio percent in [0, 100] (a LONG-side lean; higher = more net-long smart
|
|
178
|
+
money). Assets not found on the board are omitted; total<=0 -> 50.0 (neutral).
|
|
179
|
+
``agree_threshold`` is carried for signature/param parity — the alignment threshold
|
|
180
|
+
is applied by the scorer that consumes this feature (corpus: stag 0.60, hawk 0.55).
|
|
181
|
+
"""
|
|
182
|
+
markets = raw.get("data", raw) if isinstance(raw, dict) else raw
|
|
183
|
+
if isinstance(markets, dict):
|
|
184
|
+
markets = markets.get("markets", markets.get("leaderboard", markets))
|
|
185
|
+
if isinstance(markets, dict):
|
|
186
|
+
markets = markets.get("markets", [])
|
|
187
|
+
if not isinstance(markets, list):
|
|
188
|
+
markets = []
|
|
189
|
+
|
|
190
|
+
out: dict = {}
|
|
191
|
+
# Output keys preserve the asset name AS WIRED (not force-upper-cased): matching against
|
|
192
|
+
# the leaderboard is case-insensitive, but the feature_map must be keyed the same way the
|
|
193
|
+
# candles/indicator feature_maps are (which preserve case), so a scorer can join them on
|
|
194
|
+
# one asset key. Dynamic-universe names like "xyz:ACME" would otherwise mismatch.
|
|
195
|
+
for asset in sorted({str(a) for a in assets}):
|
|
196
|
+
au = str(asset).upper()
|
|
197
|
+
long_pct = 0.0
|
|
198
|
+
short_pct = 0.0
|
|
199
|
+
found = False
|
|
200
|
+
for m in markets:
|
|
201
|
+
if not isinstance(m, dict):
|
|
202
|
+
continue
|
|
203
|
+
token = str(m.get("token", m.get("coin", m.get("asset", "")))).upper()
|
|
204
|
+
if token != au:
|
|
205
|
+
continue
|
|
206
|
+
found = True
|
|
207
|
+
d = str(m.get("direction", "")).upper()
|
|
208
|
+
pct = _f(m.get("pct_of_top_traders_gain", m.get("longPct", 0)))
|
|
209
|
+
if d == "LONG":
|
|
210
|
+
long_pct = pct
|
|
211
|
+
elif d == "SHORT":
|
|
212
|
+
short_pct = pct
|
|
213
|
+
if not found:
|
|
214
|
+
continue
|
|
215
|
+
total = long_pct + short_pct
|
|
216
|
+
out[asset] = 50.0 if total <= 0 else (long_pct / total) * 100.0
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ============================================================ world-coverage (batch C)
|
|
221
|
+
# Every normalizer below is PURE (raw MCP response -> typed output ports); the adapter does
|
|
222
|
+
# the actual read. Response shapes are ported 1:1 from src/senpi/client.ts call sites cited
|
|
223
|
+
# in /Users/duncan/Senpi/.context/strategy-composer/mcp-surface-recon.md §2, verified against
|
|
224
|
+
# the scaffold surface — no speculative fields.
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def market_prices(raw, *, assets: list) -> dict:
|
|
228
|
+
"""Reduce a ``market_get_prices`` response to a per-asset price feature_map. Port:
|
|
229
|
+
prices (feature_map). Response: ``{prices: {<asset-or-"dex:asset">: number}}`` (or a
|
|
230
|
+
bare map). Mirrors sm_lean's join semantics: the price map is filtered to the wired
|
|
231
|
+
``assets`` and keyed AS WIRED (matched case-insensitively), so it joins the case-
|
|
232
|
+
preserving candle/indicator feature_maps on one asset key. Assets absent from the
|
|
233
|
+
response are omitted.
|
|
234
|
+
"""
|
|
235
|
+
prices = raw.get("prices", raw) if isinstance(raw, dict) else raw
|
|
236
|
+
if not isinstance(prices, dict):
|
|
237
|
+
return {}
|
|
238
|
+
by_upper = {str(k).upper(): v for k, v in prices.items()}
|
|
239
|
+
out: dict = {}
|
|
240
|
+
for asset in sorted({str(a) for a in assets}):
|
|
241
|
+
v = by_upper.get(str(asset).upper())
|
|
242
|
+
if v is None:
|
|
243
|
+
continue
|
|
244
|
+
out[asset] = _f(v)
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def strategy_list(raw) -> list:
|
|
249
|
+
"""Normalize ``strategy_list`` into a list of strategy-metadata RECORDS. Port:
|
|
250
|
+
strategies (record_list). Response: ``{data:{strategies:[{id, strategyWalletAddress,
|
|
251
|
+
traderAddress, status, strategyType, strategyName, createdAt, totalFunded}]}}``. Fields
|
|
252
|
+
ported 1:1; ``total_funded`` coerced to float. Sorted by ``id`` for determinism.
|
|
253
|
+
"""
|
|
254
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else raw
|
|
255
|
+
rows = data.get("strategies", data) if isinstance(data, dict) else data
|
|
256
|
+
if not isinstance(rows, list):
|
|
257
|
+
return []
|
|
258
|
+
out: list = []
|
|
259
|
+
for r in rows:
|
|
260
|
+
if not isinstance(r, dict):
|
|
261
|
+
continue
|
|
262
|
+
out.append({
|
|
263
|
+
"id": str(r.get("id", "")),
|
|
264
|
+
"wallet": str(r.get("strategyWalletAddress", "")),
|
|
265
|
+
"trader": str(r.get("traderAddress", "")),
|
|
266
|
+
"status": str(r.get("status", "")),
|
|
267
|
+
"strategy_type": str(r.get("strategyType", "")),
|
|
268
|
+
"name": str(r.get("strategyName", "")),
|
|
269
|
+
"created_at": str(r.get("createdAt", "")),
|
|
270
|
+
"total_funded": _f(r.get("totalFunded", 0)),
|
|
271
|
+
})
|
|
272
|
+
return sorted(out, key=lambda x: x["id"])
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def open_orders(raw) -> list:
|
|
276
|
+
"""Normalize ``strategy_get_open_orders`` into a list of order RECORDS. Port: orders
|
|
277
|
+
(record_list). Response: ``{orders:[{orderId, coin, side, size, price, orderType,
|
|
278
|
+
status, timestamp}]}`` (or a bare array). Multiple orders can exist per coin, so this
|
|
279
|
+
is a record list, not an asset-keyed map. Sorted by (coin, orderId) for determinism.
|
|
280
|
+
"""
|
|
281
|
+
orders = raw.get("orders", raw) if isinstance(raw, dict) else raw
|
|
282
|
+
if not isinstance(orders, list):
|
|
283
|
+
return []
|
|
284
|
+
out: list = []
|
|
285
|
+
for o in orders:
|
|
286
|
+
if not isinstance(o, dict):
|
|
287
|
+
continue
|
|
288
|
+
out.append({
|
|
289
|
+
"order_id": str(o.get("orderId", "")),
|
|
290
|
+
"coin": str(o.get("coin", "")).upper(),
|
|
291
|
+
"side": str(o.get("side", "")),
|
|
292
|
+
"size": _f(o.get("size", 0)),
|
|
293
|
+
"price": _f(o.get("price", 0)),
|
|
294
|
+
"order_type": str(o.get("orderType", "")),
|
|
295
|
+
"status": str(o.get("status", "")),
|
|
296
|
+
"timestamp": _f(o.get("timestamp", 0)),
|
|
297
|
+
})
|
|
298
|
+
return sorted(out, key=lambda x: (x["coin"], x["order_id"]))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def trader_history(raw) -> list:
|
|
302
|
+
"""Normalize ``discovery_get_trader_history`` into a list of CLOSED-trade RECORDS.
|
|
303
|
+
Port: closed_positions (record_list). Response: ``{closed_positions:[{coin, dex?, type,
|
|
304
|
+
entryPx, exitPx, szi, realizedPnl, closeTime, openTime, totalFees}]}``. ``closeTime`` /
|
|
305
|
+
``openTime`` are Unix SECONDS (carried through unchanged). Sorted by (closeTime, coin).
|
|
306
|
+
"""
|
|
307
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else raw
|
|
308
|
+
rows = data.get("closed_positions", data) if isinstance(data, dict) else data
|
|
309
|
+
if not isinstance(rows, list):
|
|
310
|
+
return []
|
|
311
|
+
out: list = []
|
|
312
|
+
for r in rows:
|
|
313
|
+
if not isinstance(r, dict):
|
|
314
|
+
continue
|
|
315
|
+
out.append({
|
|
316
|
+
"coin": str(r.get("coin", "")).upper(),
|
|
317
|
+
"dex": str(r.get("dex", "")),
|
|
318
|
+
"type": str(r.get("type", "")),
|
|
319
|
+
"entry_px": _f(r.get("entryPx", 0)),
|
|
320
|
+
"exit_px": _f(r.get("exitPx", 0)),
|
|
321
|
+
"szi": _f(r.get("szi", 0)),
|
|
322
|
+
"realized_pnl": _f(r.get("realizedPnl", 0)),
|
|
323
|
+
"total_fees": _f(r.get("totalFees", 0)),
|
|
324
|
+
"close_time": _f(r.get("closeTime", 0)),
|
|
325
|
+
"open_time": _f(r.get("openTime", 0)),
|
|
326
|
+
})
|
|
327
|
+
return sorted(out, key=lambda x: (x["close_time"], x["coin"]))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def asset_trading_limits(raw_by_asset: dict) -> dict:
|
|
331
|
+
"""Reduce per-asset ``strategy_get_asset_trading_limits`` responses to a per-asset venue
|
|
332
|
+
max-leverage feature_map. Port: max_leverage (feature_map). The adapter fans the single-
|
|
333
|
+
asset MCP call out over the universe (mirroring asset_data's fanout); each response is
|
|
334
|
+
``{leverage:{value}}`` or ``{maxLeverage}``. A missing / falsy response drops the asset.
|
|
335
|
+
"""
|
|
336
|
+
out: dict = {}
|
|
337
|
+
for asset in sorted(raw_by_asset):
|
|
338
|
+
raw = raw_by_asset[asset]
|
|
339
|
+
if not isinstance(raw, dict):
|
|
340
|
+
continue
|
|
341
|
+
lev = raw.get("leverage")
|
|
342
|
+
if isinstance(lev, dict):
|
|
343
|
+
val = _f(lev.get("value", 0))
|
|
344
|
+
else:
|
|
345
|
+
val = _f(raw.get("maxLeverage", raw.get("max_leverage", 0)))
|
|
346
|
+
if val > 0:
|
|
347
|
+
out[asset] = val
|
|
348
|
+
return out
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def strategy_pnl_snapshot(raw) -> tuple[float, float, float]:
|
|
352
|
+
"""Reduce ``strategy_get_pnl_and_account_value_history`` to today's snapshot scalars.
|
|
353
|
+
Ports: today_pnl (scalar), today_account_value (scalar), today_drawdown (scalar).
|
|
354
|
+
Response: ``{history_by_period:{...}, today_snapshot?:{pnl, account_value, drawdown}}``.
|
|
355
|
+
Only ``today_snapshot`` is decomposed (each field an account-level scalar); the nested
|
|
356
|
+
multi-period ``history_by_period`` is NOT exposed (it needs record_list — deferred). A
|
|
357
|
+
missing snapshot yields (0.0, 0.0, 0.0).
|
|
358
|
+
"""
|
|
359
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else {}
|
|
360
|
+
if not isinstance(data, dict):
|
|
361
|
+
return 0.0, 0.0, 0.0
|
|
362
|
+
snap = data.get("today_snapshot")
|
|
363
|
+
if not isinstance(snap, dict):
|
|
364
|
+
return 0.0, 0.0, 0.0
|
|
365
|
+
return (
|
|
366
|
+
_f(snap.get("pnl", 0)),
|
|
367
|
+
_f(snap.get("account_value", 0)),
|
|
368
|
+
_f(snap.get("drawdown", 0)),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _trader_held_equity(t: dict) -> tuple[list, float]:
|
|
373
|
+
"""Shared (held, equity) reduction for one TraderState record — the core both the
|
|
374
|
+
single-wallet ``trader_state`` and (indirectly) the batch ``trader_states`` build on.
|
|
375
|
+
Mirrors clearinghouse_state's read-sanity guard: notional/margin in use but an EMPTY
|
|
376
|
+
positions list is a corrupt read -> ([], 0.0).
|
|
377
|
+
"""
|
|
378
|
+
if not isinstance(t, dict):
|
|
379
|
+
return [], 0.0
|
|
380
|
+
ms = t.get("marginSummary", {}) if isinstance(t.get("marginSummary"), dict) else {}
|
|
381
|
+
equity = _f(ms.get("accountValue", 0))
|
|
382
|
+
|
|
383
|
+
held: set = set()
|
|
384
|
+
for pos in t.get("openPositions", []) or []:
|
|
385
|
+
if not isinstance(pos, dict):
|
|
386
|
+
continue
|
|
387
|
+
if _f(pos.get("szi", 0)) != 0:
|
|
388
|
+
coin = str(pos.get("coin", "")).upper()
|
|
389
|
+
if coin:
|
|
390
|
+
held.add(coin)
|
|
391
|
+
|
|
392
|
+
in_use = max(_f(ms.get("totalMarginUsed", 0)), abs(_f(ms.get("totalNtlPos", 0))))
|
|
393
|
+
if in_use > 1.0 and not held:
|
|
394
|
+
return [], 0.0
|
|
395
|
+
return sorted(held), equity
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def trader_state(raw) -> tuple[list, float]:
|
|
399
|
+
"""Reduce a ``discovery_get_trader_state`` response to (held, equity) for an ARBITRARY
|
|
400
|
+
wallet. Ports: held (asset_list), equity (scalar). This is clearinghouse_state's twin
|
|
401
|
+
generalized off the strategy's own wallet (recon §2g / §7) — same (held, equity)
|
|
402
|
+
contract, enabling copy-trading / leaderboard-following archetypes. Response:
|
|
403
|
+
``{traders:[{address, openPositions:[{coin, szi, ...}], marginSummary:{accountValue}}]}``.
|
|
404
|
+
Shares its (held, equity) + read-sanity core with the batch ``trader_states`` node.
|
|
405
|
+
"""
|
|
406
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else {}
|
|
407
|
+
if not isinstance(data, dict):
|
|
408
|
+
return [], 0.0
|
|
409
|
+
traders = data.get("traders", [])
|
|
410
|
+
if not isinstance(traders, list) or not traders:
|
|
411
|
+
return [], 0.0
|
|
412
|
+
return _trader_held_equity(traders[0] if isinstance(traders[0], dict) else {})
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ================================================================= wave A (2026-07-16)
|
|
416
|
+
# The remaining scanner-usable Senpi MCP read surface, verified against a live prod
|
|
417
|
+
# tools/list enumeration and the server's main-branch handlers/types (cited per fn) plus
|
|
418
|
+
# corpus call sites under ~/Senpi/skills/strategies/. Every normalizer is PURE; the adapter
|
|
419
|
+
# does the I/O (including pagination + 50-address chunking). All string|number unions coerce
|
|
420
|
+
# via _f. Response envelope everywhere: {success, data, meta} (data-only shown below).
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _epoch_seconds(v) -> float:
|
|
424
|
+
"""Normalize a timestamp that may arrive as unix-SECONDS (int/float or numeric string)
|
|
425
|
+
OR an ISO-8601 string into float epoch-seconds; 0.0 on anything unparseable. TopTrader
|
|
426
|
+
rows carry ``lastTradeTimestamp`` in either form (server-dependent), so it is normalized
|
|
427
|
+
to one numeric type for the record_list consumer.
|
|
428
|
+
"""
|
|
429
|
+
if v is None or isinstance(v, bool):
|
|
430
|
+
return 0.0
|
|
431
|
+
if isinstance(v, (int, float)):
|
|
432
|
+
return float(v)
|
|
433
|
+
s = str(v).strip()
|
|
434
|
+
if not s:
|
|
435
|
+
return 0.0
|
|
436
|
+
try:
|
|
437
|
+
return float(s) # numeric string (unix seconds)
|
|
438
|
+
except (TypeError, ValueError):
|
|
439
|
+
pass
|
|
440
|
+
try:
|
|
441
|
+
from datetime import datetime, timezone
|
|
442
|
+
|
|
443
|
+
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
444
|
+
if dt.tzinfo is None:
|
|
445
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
446
|
+
return dt.timestamp()
|
|
447
|
+
except (TypeError, ValueError):
|
|
448
|
+
return 0.0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def funding_regime(raw) -> tuple[str, list]:
|
|
452
|
+
"""Reduce ``market_get_funding_regime`` to (regime_label, [companions_record]). Ports:
|
|
453
|
+
regime (label), companions (record_list). Response data (leaderboard.tools funding-regime
|
|
454
|
+
client): ``{regime, long_funding_assets, short_funding_assets, avg_funding_annualized,
|
|
455
|
+
extreme_funding_count, regime_duration_hours, timestamp}`` — ONE market-wide object (dog
|
|
456
|
+
reads ``data.regime`` at dog/main/scanners/scan.py:123-125). ``regime`` is a bare label
|
|
457
|
+
string; the numerics ride a one-row record_list so `label` stays purely categorical.
|
|
458
|
+
POLARITY (see the NodeDef): LONG_CROWDED = longs PAY (opposite of funding_history's
|
|
459
|
+
funding_direction where LONG = longs COLLECT). Missing/failed read -> ("", []).
|
|
460
|
+
"""
|
|
461
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else {}
|
|
462
|
+
if not isinstance(data, dict) or not data:
|
|
463
|
+
return "", []
|
|
464
|
+
regime = str(data.get("regime", "")).upper()
|
|
465
|
+
companions = [{
|
|
466
|
+
"long_funding_assets": _f(data.get("long_funding_assets", 0)),
|
|
467
|
+
"short_funding_assets": _f(data.get("short_funding_assets", 0)),
|
|
468
|
+
"avg_funding_annualized": _f(data.get("avg_funding_annualized", 0)),
|
|
469
|
+
"extreme_funding_count": _f(data.get("extreme_funding_count", 0)),
|
|
470
|
+
"regime_duration_hours": _f(data.get("regime_duration_hours", 0)),
|
|
471
|
+
"timestamp": _epoch_seconds(data.get("timestamp")),
|
|
472
|
+
}]
|
|
473
|
+
return regime, companions
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def top_traders(raw_pages) -> list:
|
|
477
|
+
"""Normalize the paginated ``discovery_get_top_traders`` pages into a record_list of
|
|
478
|
+
trader rows. Port: traders (record_list). Each page's data is ``{traders:[...], count,
|
|
479
|
+
filters}`` (discovery.tools.ts:550-565); the adapter loops offsets (NO page_info — it
|
|
480
|
+
terminates on a short page) and hands the list of page responses here. Rows are deduped
|
|
481
|
+
by address ACROSS pages, order preserved (server rank). Address + realized-PnL accessors
|
|
482
|
+
(multi-key camel/snake) are the corpus-verified shape (whalehunter scoring.realized /
|
|
483
|
+
trader_address at scoring.py:33-37,70-75); the metric fields mirror the sort_by enum
|
|
484
|
+
(RETURN_ON_INVESTMENT / PROFIT_AND_LOSS / WIN_RATE / MAX_DRAWDOWN / GAIN_TO_PAIN_RATIO);
|
|
485
|
+
lastTradeTimestamp is normalized off its unix-sec|ISO union.
|
|
486
|
+
"""
|
|
487
|
+
out: list = []
|
|
488
|
+
seen: set = set()
|
|
489
|
+
for resp in raw_pages or []:
|
|
490
|
+
if not resp:
|
|
491
|
+
continue
|
|
492
|
+
data = resp.get("data", resp) if isinstance(resp, dict) else resp
|
|
493
|
+
rows = data.get("traders", data) if isinstance(data, dict) else data
|
|
494
|
+
if isinstance(rows, dict):
|
|
495
|
+
rows = rows.get("traders", [])
|
|
496
|
+
if not isinstance(rows, list):
|
|
497
|
+
continue
|
|
498
|
+
for t in rows:
|
|
499
|
+
if not isinstance(t, dict):
|
|
500
|
+
continue
|
|
501
|
+
addr = str(t.get("address") or t.get("trader_address") or "").lower()
|
|
502
|
+
if not addr or addr in seen:
|
|
503
|
+
continue
|
|
504
|
+
seen.add(addr)
|
|
505
|
+
out.append({
|
|
506
|
+
"address": addr,
|
|
507
|
+
"roi": _f(t.get("returnOnInvestment", t.get("return_on_investment", t.get("roi", 0)))),
|
|
508
|
+
"pnl": _f(t.get("profitAndLoss", t.get("profit_and_loss", t.get("pnl", 0)))),
|
|
509
|
+
"realized_pnl": _f(t.get("realizedProfitAndLoss",
|
|
510
|
+
t.get("realized_profit_and_loss",
|
|
511
|
+
t.get("profit_and_loss_realized",
|
|
512
|
+
t.get("realizedPnl", t.get("realized_pnl", 0)))))),
|
|
513
|
+
"win_rate": _f(t.get("winRate", t.get("win_rate", 0))),
|
|
514
|
+
"max_drawdown": _f(t.get("maxDrawdown", t.get("max_drawdown", 0))),
|
|
515
|
+
"gain_to_pain": _f(t.get("gainToPainRatio", t.get("gain_to_pain_ratio", 0))),
|
|
516
|
+
"last_trade_ts": _epoch_seconds(
|
|
517
|
+
t.get("lastTradeTimestamp", t.get("last_trade_timestamp", t.get("lastTradeTs")))),
|
|
518
|
+
})
|
|
519
|
+
return out
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _norm_open_position(p: dict) -> dict:
|
|
523
|
+
"""One TraderState openPositions[] row (discovery.tools.ts:103-128 normalizeFallbackPosition
|
|
524
|
+
shape) with every string|number union coerced to float. leverage is {type, value}."""
|
|
525
|
+
lev = p.get("leverage")
|
|
526
|
+
lev_value = _f(lev.get("value", 0)) if isinstance(lev, dict) else _f(lev)
|
|
527
|
+
cf = p.get("cumFunding") if isinstance(p.get("cumFunding"), dict) else {}
|
|
528
|
+
return {
|
|
529
|
+
"coin": str(p.get("coin", p.get("asset", ""))).upper(),
|
|
530
|
+
"szi": _f(p.get("szi", p.get("size", 0))),
|
|
531
|
+
"entry_px": _f(p.get("entryPx", p.get("entry_price", 0))),
|
|
532
|
+
"position_value": _f(p.get("positionValue", p.get("position_value", 0))),
|
|
533
|
+
"unrealized_pnl": _f(p.get("unrealizedPnl", p.get("unrealized_pnl", 0))),
|
|
534
|
+
"leverage": lev_value,
|
|
535
|
+
"margin_used": _f(p.get("marginUsed", p.get("margin_used", 0))),
|
|
536
|
+
"return_on_equity": _f(p.get("returnOnEquity", p.get("return_on_equity", 0))),
|
|
537
|
+
"liquidation_px": _f(p.get("liquidationPx", p.get("liquidation_px", 0))),
|
|
538
|
+
"cum_funding": _f(cf.get("sinceOpen", cf.get("allTime", 0))),
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _norm_trader_record(t: dict, data_source: str) -> dict:
|
|
543
|
+
"""One full TraderState record (discovery.tools.ts:95-154). marginSummary + a coerced
|
|
544
|
+
marginPercentage + the full openPositions[] detail. ``data_source`` is stamped onto EACH
|
|
545
|
+
record so a record_list consumer can see it: when it is 'hyperliquid_clearinghouse_state'
|
|
546
|
+
(the fallback when the discovery service is transiently down) openOrders is always []."""
|
|
547
|
+
ms = t.get("marginSummary", {}) if isinstance(t.get("marginSummary"), dict) else {}
|
|
548
|
+
positions = [
|
|
549
|
+
_norm_open_position(p)
|
|
550
|
+
for p in (t.get("openPositions") or t.get("open_positions") or [])
|
|
551
|
+
if isinstance(p, dict)
|
|
552
|
+
]
|
|
553
|
+
return {
|
|
554
|
+
"address": str(t.get("address") or t.get("trader_address") or "").lower(),
|
|
555
|
+
"account_value": _f(ms.get("accountValue", 0)),
|
|
556
|
+
"total_margin_used": _f(ms.get("totalMarginUsed", 0)),
|
|
557
|
+
"total_ntl_pos": _f(ms.get("totalNtlPos", 0)),
|
|
558
|
+
"margin_percentage": _f(t.get("marginPercentage", 0)),
|
|
559
|
+
"withdrawable": _f(t.get("withdrawable", 0)),
|
|
560
|
+
"data_source": str(data_source or ""),
|
|
561
|
+
"open_positions": positions,
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def trader_states(raw_pages) -> list:
|
|
566
|
+
"""Normalize the chunked batch ``discovery_get_trader_state`` pages into a record_list of
|
|
567
|
+
FULL TraderState records — the per-position detail the single-wallet ``trader_state`` node
|
|
568
|
+
discards. Port: traders (record_list). Each page's data is ``{traders:[...], count,
|
|
569
|
+
data_source, ...}`` (discovery.tools.ts:295-306); the adapter chunks addresses at the
|
|
570
|
+
server-enforced max 50 and hands the page responses here. Per-page ``data_source`` is
|
|
571
|
+
stamped onto every record (see _norm_trader_record). Order preserved (input address order).
|
|
572
|
+
"""
|
|
573
|
+
out: list = []
|
|
574
|
+
for resp in raw_pages or []:
|
|
575
|
+
if not resp:
|
|
576
|
+
continue
|
|
577
|
+
data = resp.get("data", resp) if isinstance(resp, dict) else resp
|
|
578
|
+
if not isinstance(data, dict):
|
|
579
|
+
continue
|
|
580
|
+
ds = str(data.get("data_source", ""))
|
|
581
|
+
traders = data.get("traders", [])
|
|
582
|
+
if not isinstance(traders, list):
|
|
583
|
+
continue
|
|
584
|
+
for t in traders:
|
|
585
|
+
if isinstance(t, dict):
|
|
586
|
+
out.append(_norm_trader_record(t, ds))
|
|
587
|
+
return out
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def funding_history(raw) -> list:
|
|
591
|
+
"""Normalize ``market_get_funding_history`` into a record_list of per-asset funding rows.
|
|
592
|
+
Port: assets (record_list). DOUBLE-NESTED envelope: rows live at ``data.data`` (a known
|
|
593
|
+
server quirk, parsed verbatim from pangolin/main/scanners/scan.py:130-156 and
|
|
594
|
+
dog/main/scanners/scan.py:131-154). Each row keeps BOTH enums SEPARATE — funding_direction
|
|
595
|
+
(LONG|SHORT, who COLLECTS) and funding_trend (INTENSIFYING|STABLE|DECAYING, preserved
|
|
596
|
+
verbatim, NOT remapped) — plus current_funding_8h, funding_annualized_pct, persistence_hours,
|
|
597
|
+
and the embedded funding_history_24h hourly series (carried through unchanged). Sorted by
|
|
598
|
+
asset for determinism.
|
|
599
|
+
"""
|
|
600
|
+
outer = raw.get("data", raw) if isinstance(raw, dict) else raw
|
|
601
|
+
rows = outer.get("data") if isinstance(outer, dict) else None
|
|
602
|
+
if not isinstance(rows, list):
|
|
603
|
+
rows = outer if isinstance(outer, list) else None # tolerate single-level {data:[...]}
|
|
604
|
+
if not isinstance(rows, list):
|
|
605
|
+
return []
|
|
606
|
+
out: list = []
|
|
607
|
+
for r in rows:
|
|
608
|
+
if not isinstance(r, dict):
|
|
609
|
+
continue
|
|
610
|
+
out.append({
|
|
611
|
+
"asset": str(r.get("asset", r.get("coin", ""))).upper(),
|
|
612
|
+
"funding_direction": str(r.get("funding_direction", r.get("fundingDirection", ""))).upper(),
|
|
613
|
+
"funding_trend": str(r.get("funding_trend", r.get("trend", ""))).upper(),
|
|
614
|
+
"current_funding_8h": _f(r.get("current_funding_8h", r.get("currentFunding8h", 0))),
|
|
615
|
+
"funding_annualized_pct": _f(r.get("funding_annualized_pct",
|
|
616
|
+
r.get("fundingAnnualizedPct", r.get("annualized_pct", 0)))),
|
|
617
|
+
"persistence_hours": _f(r.get("persistence_hours", r.get("persistenceHours", 0))),
|
|
618
|
+
"funding_history_24h": r.get("funding_history_24h", r.get("fundingHistory24h", [])) or [],
|
|
619
|
+
})
|
|
620
|
+
return sorted(out, key=lambda x: x["asset"])
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def cross_asset_flows(raw) -> tuple[list, list]:
|
|
624
|
+
"""Reduce ``market_get_cross_asset_flows`` to (laggards, [leader_header]). Ports: laggards
|
|
625
|
+
(record_list), leader (record_list). Response is a HEADER + LIST, not flat (mantis
|
|
626
|
+
scoring.unwrap_flow_response at mantis/main/scanners/scoring.py:47-72; osprey leader-block
|
|
627
|
+
probe at osprey/main/scanners/scan.py:216-226): ``data = {leader:{asset, move_pct,
|
|
628
|
+
direction, sm_aligned}, laggards:[{asset, follow_rate, confidence, gap_pct, ...}]}``.
|
|
629
|
+
Laggard order is preserved (server ranks by confidence). Empty/absent leader -> [].
|
|
630
|
+
"""
|
|
631
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else raw
|
|
632
|
+
if not isinstance(data, dict):
|
|
633
|
+
return [], []
|
|
634
|
+
leader_blk = data.get("leader", data.get("leader_move"))
|
|
635
|
+
leader_rows: list = []
|
|
636
|
+
if isinstance(leader_blk, dict) and leader_blk:
|
|
637
|
+
leader_rows = [{
|
|
638
|
+
"asset": str(leader_blk.get("asset", leader_blk.get("coin", ""))).upper(),
|
|
639
|
+
"move_pct": _f(leader_blk.get("move_pct", leader_blk.get("magnitude", 0))),
|
|
640
|
+
"direction": str(leader_blk.get("direction", "")).upper(),
|
|
641
|
+
"sm_aligned": bool(leader_blk.get("sm_aligned", False)),
|
|
642
|
+
}]
|
|
643
|
+
laggards: list = []
|
|
644
|
+
for lag in data.get("laggards", []) or []:
|
|
645
|
+
if not isinstance(lag, dict):
|
|
646
|
+
continue
|
|
647
|
+
laggards.append({
|
|
648
|
+
"asset": str(lag.get("asset", lag.get("coin", ""))).upper(),
|
|
649
|
+
"follow_rate": _f(lag.get("follow_rate", 0)),
|
|
650
|
+
"confidence": _f(lag.get("confidence", 0)),
|
|
651
|
+
"gap_pct": _f(lag.get("gap_pct", 0)),
|
|
652
|
+
"expected_move": _f(lag.get("expected_move", 0)),
|
|
653
|
+
"actual_move": _f(lag.get("actual_move", 0)),
|
|
654
|
+
"correlation": _f(lag.get("correlation", 0)),
|
|
655
|
+
"avg_lag_minutes": _f(lag.get("avg_lag_minutes", 0)),
|
|
656
|
+
"lag_stddev_minutes": _f(lag.get("lag_stddev_minutes", 0)),
|
|
657
|
+
"sm_starting_to_rotate": bool(lag.get("sm_starting_to_rotate", False)),
|
|
658
|
+
})
|
|
659
|
+
return laggards, leader_rows
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _norm_leaderboard_positions(rows) -> list:
|
|
663
|
+
"""Normalize leaderboard trader-position rows, uppercasing the LOWERCASE 'long'/'short'
|
|
664
|
+
direction (szi sign is the fallback, consistent with the rest of the registry — cheetah
|
|
665
|
+
scan.py:213-229, remora scoring.mirror_direction at scoring.py:92-105)."""
|
|
666
|
+
out: list = []
|
|
667
|
+
for p in rows or []:
|
|
668
|
+
if not isinstance(p, dict):
|
|
669
|
+
continue
|
|
670
|
+
coin = str(p.get("coin", p.get("market", p.get("asset", p.get("symbol", ""))))).upper()
|
|
671
|
+
if not coin:
|
|
672
|
+
continue
|
|
673
|
+
szi = _f(p.get("szi", p.get("size", 0)))
|
|
674
|
+
direction = str(p.get("direction", p.get("side", ""))).upper()
|
|
675
|
+
if direction not in ("LONG", "SHORT"):
|
|
676
|
+
direction = "LONG" if szi > 0 else ("SHORT" if szi < 0 else "LONG")
|
|
677
|
+
out.append({
|
|
678
|
+
"coin": coin,
|
|
679
|
+
"direction": direction,
|
|
680
|
+
"szi": szi,
|
|
681
|
+
"entry": _f(p.get("entry", p.get("entryPx", p.get("entry_price", 0)))),
|
|
682
|
+
"leverage": _f(p.get("leverage", 0)),
|
|
683
|
+
"notional": _f(p.get("notional", p.get("positionValue", p.get("position_value", 0)))),
|
|
684
|
+
"account_value": _f(p.get("account_value", p.get("accountValue", 0))),
|
|
685
|
+
"delta_pnl": _f(p.get("delta_pnl", p.get("deltaPnl", 0))),
|
|
686
|
+
})
|
|
687
|
+
return out
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def trader_positions(raw) -> tuple[list, list]:
|
|
691
|
+
"""Reduce ``leaderboard_get_trader_positions`` to (positions, [header]). Ports: positions
|
|
692
|
+
(record_list), header (record_list). NESTING TRAP (cheetah/main/scanners/scan.py:185-210):
|
|
693
|
+
rows live at ``data.positions.positions[]`` and the header (rank, window, total_delta_pnl)
|
|
694
|
+
at ``data.positions.*`` — one level deeper than the naive shape; both shapes tolerated.
|
|
695
|
+
Row direction is normalized to uppercase LONG/SHORT. delta_pnl is the 4h-window delta.
|
|
696
|
+
"""
|
|
697
|
+
d = raw.get("data", raw) if isinstance(raw, dict) else raw
|
|
698
|
+
if not isinstance(d, dict):
|
|
699
|
+
return [], []
|
|
700
|
+
block = d.get("positions", d)
|
|
701
|
+
if isinstance(block, list): # data.positions is the row list directly
|
|
702
|
+
return _norm_leaderboard_positions(block), []
|
|
703
|
+
if not isinstance(block, dict):
|
|
704
|
+
return [], []
|
|
705
|
+
raw_positions = block.get("positions", block.get("top_positions", []))
|
|
706
|
+
if isinstance(raw_positions, list):
|
|
707
|
+
rows = raw_positions
|
|
708
|
+
elif isinstance(raw_positions, dict):
|
|
709
|
+
nested = raw_positions.get("positions", [])
|
|
710
|
+
rows = nested if isinstance(nested, list) else []
|
|
711
|
+
else:
|
|
712
|
+
rows = []
|
|
713
|
+
header = [{
|
|
714
|
+
"rank": _f(block.get("rank", 0)),
|
|
715
|
+
"window": str(block.get("window", "")),
|
|
716
|
+
"total_delta_pnl": _f(block.get("total_delta_pnl", block.get("totalDeltaPnl", 0))),
|
|
717
|
+
}]
|
|
718
|
+
return _norm_leaderboard_positions(rows), header
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# ================================================================= wave B (2026-07-16)
|
|
722
|
+
# The LAST scanner-usable Senpi MCP reads, closing the read surface to fully-mapped. Response
|
|
723
|
+
# shapes are cited per fn against the server main-branch handlers (mcp-main/tools/*.ts) since
|
|
724
|
+
# these have NO corpus call sites (newly discovered off the live tools/list enumeration).
|
|
725
|
+
# Envelope everywhere: {success, data, meta}; the handler's createResponse payload lands under
|
|
726
|
+
# ``data`` (data-only shown below). All string|number unions coerce via _f.
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _norm_momentum_event(e: dict) -> dict:
|
|
730
|
+
"""One leaderboard_get_momentum_events row (leaderboard.tools.ts:221-249 handler +
|
|
731
|
+
tool description: trader address, tier, timestamps, top_positions). Address lower-cased,
|
|
732
|
+
tier/timestamp coerced, top_positions[] carried through."""
|
|
733
|
+
return {
|
|
734
|
+
"trader_address": str(e.get("trader_address") or e.get("traderAddress")
|
|
735
|
+
or e.get("address") or "").lower(),
|
|
736
|
+
"tier": _f(e.get("tier", 0)),
|
|
737
|
+
"timestamp": _epoch_seconds(e.get("timestamp", e.get("created_at", e.get("createdAt")))),
|
|
738
|
+
"top_positions": e.get("top_positions", e.get("topPositions", [])) or [],
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def momentum_events(raw) -> tuple[list, list]:
|
|
743
|
+
"""Reduce ``leaderboard_get_momentum_events`` to (events, [header]). Ports: events
|
|
744
|
+
(record_list), header (record_list). Response data (leaderboard.tools.ts:236-244
|
|
745
|
+
``createResponse(tool,'read',{events})``): ``{events:{events:[...], query?, total_count?}}``
|
|
746
|
+
— rows at data.events.events[], header (query echo + total_count) at data.events.*. A bare
|
|
747
|
+
data.events list is tolerated (-> no header). Most-recent-first order preserved.
|
|
748
|
+
"""
|
|
749
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else {}
|
|
750
|
+
if not isinstance(data, dict):
|
|
751
|
+
return [], []
|
|
752
|
+
block = data.get("events", data)
|
|
753
|
+
if isinstance(block, list): # data.events is the row list directly
|
|
754
|
+
return [_norm_momentum_event(e) for e in block if isinstance(e, dict)], []
|
|
755
|
+
if not isinstance(block, dict):
|
|
756
|
+
return [], []
|
|
757
|
+
rows = block.get("events", [])
|
|
758
|
+
rows = rows if isinstance(rows, list) else []
|
|
759
|
+
events = [_norm_momentum_event(e) for e in rows if isinstance(e, dict)]
|
|
760
|
+
header = [{
|
|
761
|
+
"query": block.get("query"),
|
|
762
|
+
"total_count": _f(block.get("total_count", block.get("totalCount", len(events)))),
|
|
763
|
+
}]
|
|
764
|
+
return events, header
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def top_strategies(raw_pages) -> list:
|
|
768
|
+
"""Normalize the paginated ``discovery_get_top_strategies`` pages into a record_list of
|
|
769
|
+
copy-strategy rows. Port: strategies (record_list). Each page's data is ``{strategies:[...],
|
|
770
|
+
count, page_info:{totalCount, hasNextPage, nextOffset}|null, generated_at, filters}``
|
|
771
|
+
(discovery.tools.ts:632-642); the adapter follows page_info.hasNextPage (this endpoint has
|
|
772
|
+
REAL pagination, unlike top_traders) and hands the list of page responses here. Rows deduped
|
|
773
|
+
by strategy_wallet across pages, server rank order (total PnL DESC) preserved. TopStrategy
|
|
774
|
+
fields per the tool description (strategy wallet, user id, copied trader, PnLs, return %,
|
|
775
|
+
funding, age, followers); camel/snake accessors; numerics coerced.
|
|
776
|
+
"""
|
|
777
|
+
out: list = []
|
|
778
|
+
seen: set = set()
|
|
779
|
+
for resp in raw_pages or []:
|
|
780
|
+
if not resp:
|
|
781
|
+
continue
|
|
782
|
+
data = resp.get("data", resp) if isinstance(resp, dict) else resp
|
|
783
|
+
rows = data.get("strategies", data) if isinstance(data, dict) else data
|
|
784
|
+
if not isinstance(rows, list):
|
|
785
|
+
continue
|
|
786
|
+
for s in rows:
|
|
787
|
+
if not isinstance(s, dict):
|
|
788
|
+
continue
|
|
789
|
+
wallet = str(s.get("strategyWalletAddress") or s.get("strategy_wallet")
|
|
790
|
+
or s.get("wallet") or "")
|
|
791
|
+
key = wallet.lower()
|
|
792
|
+
if key and key in seen:
|
|
793
|
+
continue
|
|
794
|
+
if key:
|
|
795
|
+
seen.add(key)
|
|
796
|
+
out.append({
|
|
797
|
+
"strategy_wallet": wallet,
|
|
798
|
+
"user_id": str(s.get("userId") or s.get("user_id") or ""),
|
|
799
|
+
"copied_trader": str(s.get("traderAddress") or s.get("copiedTrader")
|
|
800
|
+
or s.get("copied_trader") or s.get("shortTraderAddress") or ""),
|
|
801
|
+
"total_pnl": _f(s.get("totalPnl", s.get("total_pnl", 0))),
|
|
802
|
+
"unrealized_pnl": _f(s.get("unrealizedPnl", s.get("unrealized_pnl", 0))),
|
|
803
|
+
"realized_pnl": _f(s.get("realizedPnl", s.get("realized_pnl", 0))),
|
|
804
|
+
"return_pct": _f(s.get("returnPercentage",
|
|
805
|
+
s.get("returnPct", s.get("return_pct", 0)))),
|
|
806
|
+
"total_funded": _f(s.get("totalFunded", s.get("funding", s.get("total_funded", 0)))),
|
|
807
|
+
"age_seconds": _f(s.get("ageSeconds", s.get("age", s.get("age_seconds", 0)))),
|
|
808
|
+
"followers": _f(s.get("followers", s.get("followerCount", 0))),
|
|
809
|
+
})
|
|
810
|
+
return out
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def arena_leaderboard(raw) -> list:
|
|
814
|
+
"""Normalize ``arena_leaderboard`` into a record_list of Arena entry rows. Port: entries
|
|
815
|
+
(record_list). Response data (arena.tools.ts:113-121 ``{leaderboard:{...,entries:[...]},
|
|
816
|
+
count}``): rows at data.leaderboard.entries[]; each entry carries senpiUserId + the
|
|
817
|
+
handler-added senpiUserName. Server rank order preserved. camel/snake accessors; numerics
|
|
818
|
+
coerced. This is Arena (opt-in AI-agent prize competition), NOT Hyperliquid trader ranking.
|
|
819
|
+
"""
|
|
820
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else {}
|
|
821
|
+
if not isinstance(data, dict):
|
|
822
|
+
return []
|
|
823
|
+
lb = data.get("leaderboard", data)
|
|
824
|
+
if isinstance(lb, dict):
|
|
825
|
+
entries = lb.get("entries", [])
|
|
826
|
+
elif isinstance(lb, list):
|
|
827
|
+
entries = lb
|
|
828
|
+
else:
|
|
829
|
+
entries = []
|
|
830
|
+
if not isinstance(entries, list):
|
|
831
|
+
return []
|
|
832
|
+
out: list = []
|
|
833
|
+
for e in entries:
|
|
834
|
+
if not isinstance(e, dict):
|
|
835
|
+
continue
|
|
836
|
+
out.append({
|
|
837
|
+
"rank": _f(e.get("rank", 0)),
|
|
838
|
+
"senpi_user_id": str(e.get("senpiUserId") or e.get("senpi_user_id") or ""),
|
|
839
|
+
"senpi_user_name": str(e.get("senpiUserName") or e.get("senpi_user_name") or ""),
|
|
840
|
+
"wallet": str(e.get("walletAddress") or e.get("wallet") or "").lower(),
|
|
841
|
+
"roi": _f(e.get("roi", e.get("returnOnInvestment", 0))),
|
|
842
|
+
"pnl": _f(e.get("pnl", e.get("profitAndLoss", 0))),
|
|
843
|
+
"qualified": bool(e.get("qualified", False)),
|
|
844
|
+
})
|
|
845
|
+
return out
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _norm_leaderboard_top_row(t: dict) -> dict:
|
|
849
|
+
"""One leaderboard_get_top row (leaderboard.tools.ts:34-53; fast 4h delta-PnL 'Predators').
|
|
850
|
+
trader_id lower-cased; unrealized_pnl / pnl_percentage / ath_delta coerced; top_markets[]
|
|
851
|
+
and trader_tags[] carried through."""
|
|
852
|
+
return {
|
|
853
|
+
"rank": _f(t.get("rank", 0)),
|
|
854
|
+
"trader_id": str(t.get("trader_id") or t.get("traderId") or t.get("address") or "").lower(),
|
|
855
|
+
"unrealized_pnl": _f(t.get("unrealized_pnl", t.get("unrealizedPnl", 0))),
|
|
856
|
+
"pnl_percentage": _f(t.get("pnl_percentage", t.get("pnlPercentage", 0))),
|
|
857
|
+
"ath_delta": _f(t.get("ath_delta", t.get("athDelta", 0))),
|
|
858
|
+
"top_markets": t.get("top_markets", t.get("topMarkets", [])) or [],
|
|
859
|
+
"trader_tags": t.get("trader_tags", t.get("traderTags", [])) or [],
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def leaderboard_top(raw) -> tuple[list, list]:
|
|
864
|
+
"""Reduce ``leaderboard_get_top`` to (traders, [header]). Ports: traders (record_list),
|
|
865
|
+
header (record_list). Response data (leaderboard.tools.ts:45-48 ``{leaderboard, count}``):
|
|
866
|
+
the leaderboard object carries the rows at ``.data`` plus window/timestamp; the sibling
|
|
867
|
+
``count`` is total_traders. Rows normalized via _norm_leaderboard_top_row; header is a
|
|
868
|
+
one-row (window, timestamp, total_traders). Empty/failed read -> ([], []). This is the FAST
|
|
869
|
+
4h-window smart-money board — contrast top_traders (slow multi-window track record).
|
|
870
|
+
"""
|
|
871
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else {}
|
|
872
|
+
if not isinstance(data, dict) or not data:
|
|
873
|
+
return [], []
|
|
874
|
+
lb = data.get("leaderboard", data)
|
|
875
|
+
if isinstance(lb, list):
|
|
876
|
+
rows, header_src, count = lb, {}, data.get("count")
|
|
877
|
+
elif isinstance(lb, dict):
|
|
878
|
+
rows = lb.get("data", lb.get("traders", []))
|
|
879
|
+
rows = rows if isinstance(rows, list) else []
|
|
880
|
+
header_src, count = lb, data.get("count")
|
|
881
|
+
else:
|
|
882
|
+
return [], []
|
|
883
|
+
traders = [_norm_leaderboard_top_row(t) for t in rows if isinstance(t, dict)]
|
|
884
|
+
total = count if count is not None else header_src.get(
|
|
885
|
+
"total_traders", header_src.get("totalTraders", len(traders)))
|
|
886
|
+
header = [{
|
|
887
|
+
"window": str(header_src.get("window", "4h")),
|
|
888
|
+
"timestamp": _epoch_seconds(header_src.get("timestamp")),
|
|
889
|
+
"total_traders": _f(total),
|
|
890
|
+
}]
|
|
891
|
+
return traders, header
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def leaderboard_trader(raw) -> list:
|
|
895
|
+
"""Reduce ``leaderboard_get_trader`` to a ONE-ROW ``trader`` record_list. Port: trader
|
|
896
|
+
(record_list). Response data (leaderboard.tools.ts:88 ``{trader}``): a single object
|
|
897
|
+
``{trader_id, rank?, window, timestamp, pnl{...}, ath_delta, top_markets[], position_count}``.
|
|
898
|
+
A one-row record_list (not scalars) is honest: the row carries a nested numeric `pnl` map
|
|
899
|
+
(coerced value-wise) and array fields (top_markets[]) that don't reduce to a flat scalar
|
|
900
|
+
set. Empty/failed read -> [].
|
|
901
|
+
"""
|
|
902
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else {}
|
|
903
|
+
if not isinstance(data, dict):
|
|
904
|
+
return []
|
|
905
|
+
t = data.get("trader", data)
|
|
906
|
+
if not isinstance(t, dict) or not t:
|
|
907
|
+
return []
|
|
908
|
+
pnl = t.get("pnl") if isinstance(t.get("pnl"), dict) else {}
|
|
909
|
+
return [{
|
|
910
|
+
"trader_id": str(t.get("trader_id") or t.get("traderId") or t.get("address") or "").lower(),
|
|
911
|
+
"rank": _f(t.get("rank", 0)),
|
|
912
|
+
"window": str(t.get("window", "4h")),
|
|
913
|
+
"timestamp": _epoch_seconds(t.get("timestamp")),
|
|
914
|
+
"pnl": {str(k): _f(v) for k, v in pnl.items()},
|
|
915
|
+
"ath_delta": _f(t.get("ath_delta", t.get("athDelta", 0))),
|
|
916
|
+
"top_markets": t.get("top_markets", t.get("topMarkets", [])) or [],
|
|
917
|
+
"position_count": _f(t.get("position_count", t.get("positionCount", 0))),
|
|
918
|
+
}]
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def open_positions_realized_pnl(raw_pages) -> list:
|
|
922
|
+
"""Normalize the chunked ``discovery_get_open_position_realized_pnl`` pages into a
|
|
923
|
+
record_list of banked-realized-PnL rows. Port: realized (record_list). Each page's data is
|
|
924
|
+
``{positions_requested, latest, realized_pnl:[{walletAddress, coin, realizedPnl, totalFees}],
|
|
925
|
+
count, fetched_at}`` (discovery.tools.ts:741-747); the adapter chunks the (wallet, coin)
|
|
926
|
+
pairs at the server max of 100 and hands the page responses here. walletAddress lower-cased,
|
|
927
|
+
coin upper-cased, PnL/fees coerced. Order preserved (input pair order).
|
|
928
|
+
"""
|
|
929
|
+
out: list = []
|
|
930
|
+
for resp in raw_pages or []:
|
|
931
|
+
if not resp:
|
|
932
|
+
continue
|
|
933
|
+
data = resp.get("data", resp) if isinstance(resp, dict) else resp
|
|
934
|
+
rows = data.get("realized_pnl", data.get("realizedPnl", [])) if isinstance(data, dict) else data
|
|
935
|
+
if not isinstance(rows, list):
|
|
936
|
+
continue
|
|
937
|
+
for r in rows:
|
|
938
|
+
if not isinstance(r, dict):
|
|
939
|
+
continue
|
|
940
|
+
out.append({
|
|
941
|
+
"wallet_address": str(r.get("walletAddress") or r.get("wallet_address") or "").lower(),
|
|
942
|
+
"coin": str(r.get("coin", "")).upper(),
|
|
943
|
+
"realized_pnl": _f(r.get("realizedPnl", r.get("realized_pnl", 0))),
|
|
944
|
+
"total_fees": _f(r.get("totalFees", r.get("total_fees", 0))),
|
|
945
|
+
})
|
|
946
|
+
return out
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
_ACCOUNT_HISTORY_PERIODS = ("day", "week", "month", "allTime")
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def account_history(raw) -> list:
|
|
953
|
+
"""Reduce ``account_get_historical_info`` to a ``periods`` record_list (one row per
|
|
954
|
+
timeframe). Port: periods (record_list). Response data (account.tools.ts:159-183):
|
|
955
|
+
``{historical_info:{day, week, month, allTime}}`` where each timeframe carries
|
|
956
|
+
totalPnlByValue / totalPnlByPercentage / pnlHistory[] / accountValueHistory[]. Emits one
|
|
957
|
+
row per PRESENT timeframe in a fixed order (day, week, month, allTime), each row keeping the
|
|
958
|
+
two PnL totals (coerced) AND the two time-series (carried through). Missing/failed -> [].
|
|
959
|
+
"""
|
|
960
|
+
data = raw.get("data", raw) if isinstance(raw, dict) else {}
|
|
961
|
+
if not isinstance(data, dict):
|
|
962
|
+
return []
|
|
963
|
+
info = data.get("historical_info", data.get("historicalInfo"))
|
|
964
|
+
if not isinstance(info, dict):
|
|
965
|
+
return []
|
|
966
|
+
out: list = []
|
|
967
|
+
for period in _ACCOUNT_HISTORY_PERIODS:
|
|
968
|
+
blk = info.get(period)
|
|
969
|
+
if not isinstance(blk, dict):
|
|
970
|
+
continue
|
|
971
|
+
out.append({
|
|
972
|
+
"period": period,
|
|
973
|
+
"total_pnl_value": _f(blk.get("totalPnlByValue", blk.get("total_pnl_by_value", 0))),
|
|
974
|
+
"total_pnl_pct": _f(blk.get("totalPnlByPercentage", blk.get("total_pnl_by_percentage", 0))),
|
|
975
|
+
"pnl_history": blk.get("pnlHistory", blk.get("pnl_history", [])) or [],
|
|
976
|
+
"account_value_history": blk.get("accountValueHistory",
|
|
977
|
+
blk.get("account_value_history", [])) or [],
|
|
978
|
+
})
|
|
979
|
+
return out
|