@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.
Files changed (108) hide show
  1. package/dist/cli/composer-commands.d.ts +37 -0
  2. package/dist/cli/composer-commands.d.ts.map +1 -0
  3. package/dist/cli/composer-commands.js +396 -0
  4. package/dist/cli/composer-commands.js.map +1 -0
  5. package/dist/cli/senpi-commands.d.ts.map +1 -1
  6. package/dist/cli/senpi-commands.js +2 -0
  7. package/dist/cli/senpi-commands.js.map +1 -1
  8. package/dist/cli/validate-runtime-config.mjs +190 -0
  9. package/dist/composer/AUTHORING.md +435 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +59 -0
  12. package/dist/composer/authoring/answers.py +297 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +26 -0
  14. package/dist/composer/authoring/archetypes/_common.py +410 -0
  15. package/dist/composer/authoring/archetypes/breakout.py +134 -0
  16. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  17. package/dist/composer/authoring/archetypes/skeleton.py +76 -0
  18. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  19. package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
  20. package/dist/composer/authoring/builder.py +191 -0
  21. package/dist/composer/authoring/emit.py +264 -0
  22. package/dist/composer/authoring/gaps.py +115 -0
  23. package/dist/composer/authoring/generate.py +131 -0
  24. package/dist/composer/authoring/presets.py +168 -0
  25. package/dist/composer/authoring/runtime_emit.py +266 -0
  26. package/dist/composer/authoring/strategy_manifest.py +94 -0
  27. package/dist/composer/cli.py +487 -0
  28. package/dist/composer/compiler/__init__.py +22 -0
  29. package/dist/composer/compiler/compile.py +739 -0
  30. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  31. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  32. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  33. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  34. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  35. package/dist/composer/generated/README.md +22 -0
  36. package/dist/composer/generated/catalog.md +118 -0
  37. package/dist/composer/generated/describe.md +1168 -0
  38. package/dist/composer/generated/graph.schema.json +1946 -0
  39. package/dist/composer/primitives/__init__.py +320 -0
  40. package/dist/composer/primitives/_candles.py +76 -0
  41. package/dist/composer/primitives/adapter.py +363 -0
  42. package/dist/composer/primitives/emitters.py +142 -0
  43. package/dist/composer/primitives/filters.py +71 -0
  44. package/dist/composer/primitives/indicators.py +326 -0
  45. package/dist/composer/primitives/rankers.py +40 -0
  46. package/dist/composer/primitives/scorers.py +100 -0
  47. package/dist/composer/primitives/sources.py +979 -0
  48. package/dist/composer/primitives/stateful.py +254 -0
  49. package/dist/composer/pure_fn/__init__.py +34 -0
  50. package/dist/composer/pure_fn/checks.py +39 -0
  51. package/dist/composer/pure_fn/models.py +177 -0
  52. package/dist/composer/pure_fn/purity.py +311 -0
  53. package/dist/composer/pure_fn/runtests.py +155 -0
  54. package/dist/composer/registry/__init__.py +40 -0
  55. package/dist/composer/registry/generate.py +425 -0
  56. package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
  57. package/dist/composer/registry/mcp_closure.py +311 -0
  58. package/dist/composer/registry/models.py +417 -0
  59. package/dist/composer/registry/nodes.py +1297 -0
  60. package/dist/composer/requirements.txt +21 -0
  61. package/dist/composer/scaffold_path.py +118 -0
  62. package/dist/composer/templates/__init__.py +13 -0
  63. package/dist/composer/templates/scan.py.j2 +34 -0
  64. package/dist/composer/validator/__init__.py +29 -0
  65. package/dist/composer/validator/desugar.py +133 -0
  66. package/dist/composer/validator/errors.py +227 -0
  67. package/dist/composer/validator/graph_checks.py +443 -0
  68. package/dist/composer/validator/loader.py +110 -0
  69. package/dist/composer/validator/schema_checks.py +147 -0
  70. package/dist/composer/validator/validate.py +86 -0
  71. package/dist/composer/vendoring.py +103 -0
  72. package/dist/composer/verbs/__init__.py +49 -0
  73. package/dist/composer/verbs/_box.py +308 -0
  74. package/dist/composer/verbs/check.py +254 -0
  75. package/dist/composer/verbs/close.py +494 -0
  76. package/dist/composer/verbs/deploy.py +279 -0
  77. package/dist/composer/verbs/docs.py +30 -0
  78. package/dist/composer/verbs/fund.py +498 -0
  79. package/dist/composer/verbs/install.py +586 -0
  80. package/dist/composer/verbs/runtime_validate.py +422 -0
  81. package/dist/composer/verbs/smoke.py +134 -0
  82. package/dist/composer/verbs/status.py +383 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -9
  85. package/dist/index.js.map +1 -1
  86. package/dist/runtime/load-runtime-config.d.ts +17 -0
  87. package/dist/runtime/load-runtime-config.d.ts.map +1 -1
  88. package/dist/runtime/load-runtime-config.js +2 -2
  89. package/dist/runtime/load-runtime-config.js.map +1 -1
  90. package/dist/runtime-api/config.d.ts +2 -2
  91. package/dist/runtime-api/config.js +4 -4
  92. package/dist/runtime-api/config.js.map +1 -1
  93. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  94. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  95. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  96. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  97. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  98. package/dist/runtime-api/intake/intake-mount.js +2 -8
  99. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  100. package/dist/skills-manager/manifest.d.ts.map +1 -1
  101. package/dist/skills-manager/manifest.js +1 -3
  102. package/dist/skills-manager/manifest.js.map +1 -1
  103. package/dist/skills-manager/skills-manifest.json +1 -3
  104. package/package.json +4 -2
  105. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  106. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  107. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  108. 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