@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,363 @@
|
|
|
1
|
+
"""adapter — the thin, stubbable data-source layer over the scaffold ctx.
|
|
2
|
+
|
|
3
|
+
This is the ONLY place a compiled scan.py performs I/O: each method makes exactly one
|
|
4
|
+
``ctx.senpi_mcp.call_tool(name, args)`` read and returns the raw response, which the
|
|
5
|
+
pure ``sources`` primitives then normalize. Reads are guarded (a transient/permission
|
|
6
|
+
error on ONE read must not roll back the whole tick — corpus ``_read`` behaviour):
|
|
7
|
+
a failed read returns None and the source primitive drops that asset.
|
|
8
|
+
|
|
9
|
+
Golden tests stub source data at THIS boundary by handing scan() a ctx whose
|
|
10
|
+
``senpi_mcp`` returns fixture responses — so the compiled graph runs end-to-end over
|
|
11
|
+
fixed data while the real ctx / StateManager frozen semantics are honored.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
# server-enforced maximum trader addresses per discovery_get_trader_state call (batch chunk).
|
|
20
|
+
_TRADER_STATE_BATCH_MAX = 50
|
|
21
|
+
# server-enforced maximum (wallet, coin) pairs per discovery_get_open_position_realized_pnl call.
|
|
22
|
+
_OPEN_POSITION_REALIZED_PNL_MAX = 100
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _project_addresses(records) -> list:
|
|
26
|
+
"""Project trader addresses out of a wired record_list (each row's address /
|
|
27
|
+
trader_address / trader_id field), or pass strings through. Powers trader_states_fetch's
|
|
28
|
+
wire-from-a-record_list input (e.g. straight off top_traders_fetch.traders)."""
|
|
29
|
+
out: list = []
|
|
30
|
+
for r in records or []:
|
|
31
|
+
if isinstance(r, dict):
|
|
32
|
+
addr = r.get("address") or r.get("trader_address") or r.get("trader_id")
|
|
33
|
+
if addr:
|
|
34
|
+
out.append(str(addr))
|
|
35
|
+
elif isinstance(r, str) and r:
|
|
36
|
+
out.append(r)
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _project_wallet_coin_pairs(records) -> list:
|
|
41
|
+
"""Project (wallet_address, coin) pairs out of a wired record_list for
|
|
42
|
+
discovery_get_open_position_realized_pnl. Accepts wallet_address / walletAddress / address
|
|
43
|
+
plus coin; drops rows missing either. Powers open_positions_realized_pnl_fetch's
|
|
44
|
+
wire-from-a-record_list input (e.g. a cohort's open positions)."""
|
|
45
|
+
out: list = []
|
|
46
|
+
for r in records or []:
|
|
47
|
+
if not isinstance(r, dict):
|
|
48
|
+
continue
|
|
49
|
+
wallet = r.get("wallet_address") or r.get("walletAddress") or r.get("address")
|
|
50
|
+
coin = r.get("coin")
|
|
51
|
+
if wallet and coin:
|
|
52
|
+
out.append({"wallet_address": str(wallet), "coin": str(coin)})
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _top_strategies_next(resp) -> tuple:
|
|
57
|
+
"""(has_next, next_offset) from a discovery_get_top_strategies page's page_info
|
|
58
|
+
(this endpoint HAS real pagination). No page_info -> (False, None)."""
|
|
59
|
+
if not isinstance(resp, dict):
|
|
60
|
+
return False, None
|
|
61
|
+
data = resp.get("data", resp)
|
|
62
|
+
pi = data.get("page_info") if isinstance(data, dict) else None
|
|
63
|
+
if not isinstance(pi, dict):
|
|
64
|
+
return False, None
|
|
65
|
+
has_next = bool(pi.get("hasNextPage", pi.get("has_next_page", False)))
|
|
66
|
+
nxt = pi.get("nextOffset", pi.get("next_offset"))
|
|
67
|
+
try:
|
|
68
|
+
nxt = int(nxt) if nxt is not None else None
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
nxt = None
|
|
71
|
+
return has_next, nxt
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _top_traders_page_rows(resp) -> int:
|
|
75
|
+
"""Row count of one discovery_get_top_traders page — the pagination terminator (the
|
|
76
|
+
response has NO page_info, so a short page ends the offset loop)."""
|
|
77
|
+
if not isinstance(resp, dict):
|
|
78
|
+
return 0
|
|
79
|
+
data = resp.get("data", resp)
|
|
80
|
+
rows = data.get("traders", []) if isinstance(data, dict) else []
|
|
81
|
+
return len(rows) if isinstance(rows, list) else 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class McpSourceAdapter:
|
|
85
|
+
"""Wraps a scaffold ``ctx`` and exposes one read method per corpus MCP tool."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, ctx: Any) -> None:
|
|
88
|
+
self._ctx = ctx
|
|
89
|
+
|
|
90
|
+
def _read(self, name: str, args: dict):
|
|
91
|
+
mcp = getattr(self._ctx, "senpi_mcp", None)
|
|
92
|
+
if mcp is None:
|
|
93
|
+
return None
|
|
94
|
+
try:
|
|
95
|
+
return mcp.call_tool(name, args)
|
|
96
|
+
except Exception as exc: # noqa: BLE001 — a read error must not roll back the tick
|
|
97
|
+
print(f"[composer.adapter] {name} read failed: {exc!r}", file=sys.stderr)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def raw_read(self, tool: str, args: dict | None = None, *,
|
|
101
|
+
timeout_seconds: float = 0.0) -> Any:
|
|
102
|
+
"""The escape-hatch generic read (mcp_read source): call an ARBITRARY MCP `tool` with
|
|
103
|
+
`args` passed verbatim and return its UNWRAPPED payload — the standard ``data`` envelope,
|
|
104
|
+
untouched — so a pure_fn normalizer can turn it into a typed shape. Read-only by
|
|
105
|
+
construction: the scaffold client's own MUTATION_TOOLS blocklist rejects any write tool,
|
|
106
|
+
double-guarding the composer's check-time refusal. A failed read returns None (corpus
|
|
107
|
+
``_read`` behaviour), which the normalizer must handle. ``timeout_seconds`` is advisory:
|
|
108
|
+
the frozen scaffold client owns transport timeouts and exposes no per-call knob, so it is
|
|
109
|
+
intentionally NOT plumbed into ``call_tool`` (the arg exists for authoring legibility)."""
|
|
110
|
+
raw = self._read(tool, dict(args or {}))
|
|
111
|
+
if isinstance(raw, dict):
|
|
112
|
+
return raw.get("data", raw)
|
|
113
|
+
return raw
|
|
114
|
+
|
|
115
|
+
def asset_data(self, asset: str, intervals: list, *, include_funding: bool = False,
|
|
116
|
+
include_asset_context: bool = False, dex: str = "") -> Any:
|
|
117
|
+
args = {
|
|
118
|
+
"asset": asset,
|
|
119
|
+
"candle_intervals": list(intervals),
|
|
120
|
+
"include_funding": include_funding,
|
|
121
|
+
"include_order_book": False,
|
|
122
|
+
}
|
|
123
|
+
if include_asset_context:
|
|
124
|
+
args["include_asset_context"] = True
|
|
125
|
+
if dex:
|
|
126
|
+
args["dex"] = dex
|
|
127
|
+
return self._read("market_get_asset_data", args)
|
|
128
|
+
|
|
129
|
+
def clearinghouse(self) -> Any:
|
|
130
|
+
wallet = getattr(self._ctx, "wallet", "") or ""
|
|
131
|
+
return self._read("strategy_get_clearinghouse_state", {"strategy_wallet": wallet})
|
|
132
|
+
|
|
133
|
+
def leaderboard_markets(self) -> Any:
|
|
134
|
+
return self._read("leaderboard_get_markets", {})
|
|
135
|
+
|
|
136
|
+
def instruments(self, *, dex: str = "") -> Any:
|
|
137
|
+
return self._read("market_list_instruments", {"dex": dex})
|
|
138
|
+
|
|
139
|
+
# -------------------------------------------------- world-coverage reads (batch C)
|
|
140
|
+
|
|
141
|
+
def market_prices(self, *, dex: str = "") -> Any:
|
|
142
|
+
"""market_get_prices — all latest prices for a venue (omitting `assets` returns
|
|
143
|
+
the full map; the normalizer filters to the wired universe)."""
|
|
144
|
+
return self._read("market_get_prices", {"dex": dex})
|
|
145
|
+
|
|
146
|
+
def strategy_list(self, *, status: str = "", strategy_type: str = "") -> Any:
|
|
147
|
+
"""strategy_list — strategy-metadata rows, optionally filtered by status / type."""
|
|
148
|
+
args: dict = {}
|
|
149
|
+
if status:
|
|
150
|
+
args["status"] = status
|
|
151
|
+
if strategy_type:
|
|
152
|
+
args["strategy_type"] = strategy_type
|
|
153
|
+
return self._read("strategy_list", args)
|
|
154
|
+
|
|
155
|
+
def open_orders(self) -> Any:
|
|
156
|
+
"""strategy_get_open_orders for the strategy's own wallet (like clearinghouse)."""
|
|
157
|
+
wallet = getattr(self._ctx, "wallet", "") or ""
|
|
158
|
+
return self._read("strategy_get_open_orders", {"strategy_wallet": wallet})
|
|
159
|
+
|
|
160
|
+
def trader_history(self, *, trader_address: str, limit: int = 100) -> Any:
|
|
161
|
+
"""discovery_get_trader_history — closed-trade history for an arbitrary trader."""
|
|
162
|
+
return self._read(
|
|
163
|
+
"discovery_get_trader_history",
|
|
164
|
+
{"trader_address": trader_address, "latest": True, "limit": limit},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def asset_trading_limits(self, coin: str, *, dex: str = "") -> Any:
|
|
168
|
+
"""strategy_get_asset_trading_limits — single-asset venue limit for the own wallet
|
|
169
|
+
(fanned out per-asset by the emitted comprehension, like asset_data)."""
|
|
170
|
+
wallet = getattr(self._ctx, "wallet", "") or ""
|
|
171
|
+
args = {"strategy_wallet": wallet, "coin": coin}
|
|
172
|
+
if dex:
|
|
173
|
+
args["dex"] = dex
|
|
174
|
+
return self._read("strategy_get_asset_trading_limits", args)
|
|
175
|
+
|
|
176
|
+
def pnl_snapshot(self, *, period: str = "", drawdown_reset_on_day_rollover: bool = False) -> Any:
|
|
177
|
+
"""strategy_get_pnl_and_account_value_history for the own wallet."""
|
|
178
|
+
wallet = getattr(self._ctx, "wallet", "") or ""
|
|
179
|
+
args: dict = {"strategy_wallet": wallet}
|
|
180
|
+
if period:
|
|
181
|
+
args["period"] = period
|
|
182
|
+
if drawdown_reset_on_day_rollover:
|
|
183
|
+
args["drawdown_reset_on_day_rollover"] = True
|
|
184
|
+
return self._read("strategy_get_pnl_and_account_value_history", args)
|
|
185
|
+
|
|
186
|
+
def trader_state(self, *, trader_address: str) -> Any:
|
|
187
|
+
"""discovery_get_trader_state — an arbitrary wallet's current book (clearinghouse
|
|
188
|
+
twin). The MCP arg is a BATCH (`trader_addresses`); we read the single wired wallet."""
|
|
189
|
+
return self._read(
|
|
190
|
+
"discovery_get_trader_state",
|
|
191
|
+
{"trader_addresses": [trader_address], "latest": True},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------ wave A reads (2026-07-16)
|
|
195
|
+
|
|
196
|
+
def funding_regime(self) -> Any:
|
|
197
|
+
"""market_get_funding_regime — the market-wide crowding regime (zero params)."""
|
|
198
|
+
return self._read("market_get_funding_regime", {})
|
|
199
|
+
|
|
200
|
+
def top_traders(self, *, time_frame: str, sort_by: str = "RETURN_ON_INVESTMENT",
|
|
201
|
+
bias: str = "ALL", limit: int = 100, max_pages: int = 1,
|
|
202
|
+
open_position_filter: bool = False, activity_labels=(),
|
|
203
|
+
consistency=(), risk_labels=()) -> list:
|
|
204
|
+
"""discovery_get_top_traders — offset PAGINATION loop (the response has NO page_info):
|
|
205
|
+
fetch up to `max_pages` pages of `limit` rows and TERMINATE on a page shorter than
|
|
206
|
+
`limit`. Returns the list of raw page responses (the normalizer concatenates + dedups).
|
|
207
|
+
time_frame is REQUIRED by the advertised schema and always sent."""
|
|
208
|
+
pages: list = []
|
|
209
|
+
offset = 0
|
|
210
|
+
for _ in range(max(1, int(max_pages))):
|
|
211
|
+
args: dict = {
|
|
212
|
+
"time_frame": time_frame, "sort_by": sort_by, "bias": bias,
|
|
213
|
+
"limit": limit, "offset": offset,
|
|
214
|
+
"open_position_filter": open_position_filter,
|
|
215
|
+
}
|
|
216
|
+
if activity_labels:
|
|
217
|
+
args["activity_labels"] = list(activity_labels)
|
|
218
|
+
if consistency:
|
|
219
|
+
args["consistency"] = list(consistency)
|
|
220
|
+
if risk_labels:
|
|
221
|
+
args["risk_labels"] = list(risk_labels)
|
|
222
|
+
resp = self._read("discovery_get_top_traders", args)
|
|
223
|
+
if not resp:
|
|
224
|
+
break
|
|
225
|
+
pages.append(resp)
|
|
226
|
+
if _top_traders_page_rows(resp) < limit:
|
|
227
|
+
break
|
|
228
|
+
offset += limit
|
|
229
|
+
return pages
|
|
230
|
+
|
|
231
|
+
def trader_states(self, *, records=None, wallets=(), latest: bool = False,
|
|
232
|
+
include_position_age: bool = False, sort_open_positions_by: str = "",
|
|
233
|
+
sort_open_positions_direction: str = "DESC") -> list:
|
|
234
|
+
"""discovery_get_trader_state in BATCH — addresses from the wired `records`
|
|
235
|
+
(record_list) when present, else the static `wallets`. CHUNKS at the server-enforced
|
|
236
|
+
max of 50 addresses per call. Returns the list of raw page responses."""
|
|
237
|
+
addrs = _project_addresses(records) if records else [str(w) for w in (wallets or [])]
|
|
238
|
+
seen: set = set()
|
|
239
|
+
addresses: list = []
|
|
240
|
+
for a in addrs:
|
|
241
|
+
if a and a not in seen:
|
|
242
|
+
seen.add(a)
|
|
243
|
+
addresses.append(a)
|
|
244
|
+
pages: list = []
|
|
245
|
+
for i in range(0, len(addresses), _TRADER_STATE_BATCH_MAX):
|
|
246
|
+
chunk = addresses[i:i + _TRADER_STATE_BATCH_MAX]
|
|
247
|
+
args: dict = {"trader_addresses": chunk, "latest": latest}
|
|
248
|
+
if include_position_age:
|
|
249
|
+
args["include_position_age"] = True
|
|
250
|
+
if sort_open_positions_by:
|
|
251
|
+
args["sort_open_positions_by"] = sort_open_positions_by
|
|
252
|
+
args["sort_open_positions_direction"] = sort_open_positions_direction
|
|
253
|
+
resp = self._read("discovery_get_trader_state", args)
|
|
254
|
+
if resp:
|
|
255
|
+
pages.append(resp)
|
|
256
|
+
return pages
|
|
257
|
+
|
|
258
|
+
def funding_history(self, *, asset: str = "", min_annualized_pct: float = 20.0,
|
|
259
|
+
min_persistence_hours: float = 0.0) -> Any:
|
|
260
|
+
"""market_get_funding_history — exact snake_case (zod .strict()); `asset` sent only
|
|
261
|
+
when non-empty (omit = all extreme-funding assets)."""
|
|
262
|
+
args: dict = {
|
|
263
|
+
"min_annualized_pct": min_annualized_pct,
|
|
264
|
+
"min_persistence_hours": min_persistence_hours,
|
|
265
|
+
}
|
|
266
|
+
if asset:
|
|
267
|
+
args["asset"] = asset
|
|
268
|
+
return self._read("market_get_funding_history", args)
|
|
269
|
+
|
|
270
|
+
def cross_asset_flows(self, *, leader_asset: str = "BTC", min_move_pct: float = 2.0,
|
|
271
|
+
window: str = "4h") -> Any:
|
|
272
|
+
"""market_get_cross_asset_flows — exact snake_case (zod .strict())."""
|
|
273
|
+
return self._read("market_get_cross_asset_flows", {
|
|
274
|
+
"leader_asset": leader_asset,
|
|
275
|
+
"min_move_pct": min_move_pct,
|
|
276
|
+
"window": window,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
def trader_positions(self, *, trader_id: str) -> Any:
|
|
280
|
+
"""leaderboard_get_trader_positions — exact snake_case (zod .strict())."""
|
|
281
|
+
return self._read("leaderboard_get_trader_positions", {"trader_id": trader_id})
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------ wave B reads (2026-07-16)
|
|
284
|
+
|
|
285
|
+
def momentum_events(self, *, limit: int = 50, tier: int = 0, asset: str = "",
|
|
286
|
+
from_time: str = "", to_time: str = "") -> Any:
|
|
287
|
+
"""leaderboard_get_momentum_events — `from_time`/`to_time` map to the tool's `from`/`to`
|
|
288
|
+
(renamed off the Python keyword) and are sent only when set (else the server 4h default
|
|
289
|
+
window applies); tier 0 / empty asset are omitted (all tiers / all assets)."""
|
|
290
|
+
args: dict = {"limit": limit}
|
|
291
|
+
if tier:
|
|
292
|
+
args["tier"] = tier
|
|
293
|
+
if asset:
|
|
294
|
+
args["asset"] = asset
|
|
295
|
+
if from_time:
|
|
296
|
+
args["from"] = from_time
|
|
297
|
+
if to_time:
|
|
298
|
+
args["to"] = to_time
|
|
299
|
+
return self._read("leaderboard_get_momentum_events", args)
|
|
300
|
+
|
|
301
|
+
def top_strategies(self, *, limit: int = 20, offset: int = 0, force_fetch: bool = False,
|
|
302
|
+
max_pages: int = 1) -> list:
|
|
303
|
+
"""discovery_get_top_strategies — REAL pagination: follow page_info.hasNextPage up to
|
|
304
|
+
`max_pages`, advancing by page_info.nextOffset (fallback offset+limit). Returns the list
|
|
305
|
+
of raw page responses (the normalizer concatenates + dedups by wallet)."""
|
|
306
|
+
pages: list = []
|
|
307
|
+
cur = offset
|
|
308
|
+
for _ in range(max(1, int(max_pages))):
|
|
309
|
+
resp = self._read("discovery_get_top_strategies",
|
|
310
|
+
{"limit": limit, "offset": cur, "force_fetch": force_fetch})
|
|
311
|
+
if not resp:
|
|
312
|
+
break
|
|
313
|
+
pages.append(resp)
|
|
314
|
+
has_next, next_offset = _top_strategies_next(resp)
|
|
315
|
+
if not has_next:
|
|
316
|
+
break
|
|
317
|
+
cur = next_offset if next_offset is not None else cur + limit
|
|
318
|
+
return pages
|
|
319
|
+
|
|
320
|
+
def arena_leaderboard(self, *, period_type: str = "WEEK", period_number: int = 0,
|
|
321
|
+
limit: int = 100, offset: int = 0, qualified: bool = False) -> Any:
|
|
322
|
+
"""arena_leaderboard — period_number 0 (current period) and qualified=False are omitted
|
|
323
|
+
(server default: current period / all enrolled agents)."""
|
|
324
|
+
args: dict = {"period_type": period_type, "limit": limit, "offset": offset}
|
|
325
|
+
if period_number:
|
|
326
|
+
args["period_number"] = period_number
|
|
327
|
+
if qualified:
|
|
328
|
+
args["qualified"] = True
|
|
329
|
+
return self._read("arena_leaderboard", args)
|
|
330
|
+
|
|
331
|
+
def leaderboard_top(self, *, limit: int = 100) -> Any:
|
|
332
|
+
"""leaderboard_get_top — the fast 4h-window smart-money board."""
|
|
333
|
+
return self._read("leaderboard_get_top", {"limit": limit})
|
|
334
|
+
|
|
335
|
+
def leaderboard_trader(self, *, trader_id: str) -> Any:
|
|
336
|
+
"""leaderboard_get_trader — one trader's 4h-window headline scalars."""
|
|
337
|
+
return self._read("leaderboard_get_trader", {"trader_id": trader_id})
|
|
338
|
+
|
|
339
|
+
def open_positions_realized_pnl(self, *, records=None, latest: bool = False) -> list:
|
|
340
|
+
"""discovery_get_open_position_realized_pnl — (wallet, coin) pairs projected from the
|
|
341
|
+
wired `records` record_list, CHUNKED at the server max of 100 per call. Returns the list
|
|
342
|
+
of raw page responses. Unwired records -> [] (no call)."""
|
|
343
|
+
pairs = _project_wallet_coin_pairs(records)
|
|
344
|
+
pages: list = []
|
|
345
|
+
for i in range(0, len(pairs), _OPEN_POSITION_REALIZED_PNL_MAX):
|
|
346
|
+
chunk = pairs[i:i + _OPEN_POSITION_REALIZED_PNL_MAX]
|
|
347
|
+
resp = self._read("discovery_get_open_position_realized_pnl",
|
|
348
|
+
{"positions": chunk, "latest": latest})
|
|
349
|
+
if resp:
|
|
350
|
+
pages.append(resp)
|
|
351
|
+
return pages
|
|
352
|
+
|
|
353
|
+
def account_history(self, *, bucket_duration_ms: int = 3600000) -> Any:
|
|
354
|
+
"""account_get_historical_info — own-account bucketed PnL/value history."""
|
|
355
|
+
return self._read("account_get_historical_info",
|
|
356
|
+
{"bucket_duration_ms": bucket_duration_ms})
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def make_adapter(ctx: Any) -> McpSourceAdapter:
|
|
360
|
+
"""Factory the compiled scan.py calls. A test may monkeypatch this module attribute
|
|
361
|
+
to inject a fully synthetic adapter, but the default (a real ctx + fake senpi_mcp)
|
|
362
|
+
is the primary stubbing seam."""
|
|
363
|
+
return McpSourceAdapter(ctx)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""emitters — the signal_envelope primitive: selected -> the scan() return list.
|
|
2
|
+
|
|
3
|
+
Produces the exact top-level shape the scaffold contract expects (see
|
|
4
|
+
``src/scanners/external/scaffold/signal_envelope.py``):
|
|
5
|
+
``{asset, direction, marginPct, leverage, data:{score, reasons, ...}}`` plus the optional
|
|
6
|
+
``valid_for_seconds`` / ``signal_type`` the scaffold also recognizes on a candidate.
|
|
7
|
+
|
|
8
|
+
Supervisor decision (slice 3): FINAL sizing lives here. ``marginPct`` is emitted as a
|
|
9
|
+
PERCENT of withdrawable in (0, 100] (never a fraction, never USD) — the scaffold
|
|
10
|
+
LOUD-rejects anything outside that band. Conviction/tier scorers only produce inputs
|
|
11
|
+
to a score; the margin decision is the emitter's.
|
|
12
|
+
|
|
13
|
+
================================================================================
|
|
14
|
+
ENVELOPE SURFACE AUDIT (batch D) — every field a scanner may set on a signal.
|
|
15
|
+
Source of truth: src/scanners/external/scaffold/signal_envelope.py::candidate_to_signal
|
|
16
|
+
(_RECOGNIZED_CANDIDATE_KEYS + its per-field validation). "Covered" = expressible by this
|
|
17
|
+
emitter after batch D.
|
|
18
|
+
|
|
19
|
+
field req/opt constraint (scaffold reject rule) covered by
|
|
20
|
+
----------------- -------- --------------------------------------------- -------------------------
|
|
21
|
+
asset REQUIRED non-empty string selected keys (dynamic)
|
|
22
|
+
direction REQUIRED long/short (case-normalized -> LONG/SHORT) entry["direction"] (dynamic)
|
|
23
|
+
marginPct opt number in (0,100] margin_pct param OR entry["marginPct"]
|
|
24
|
+
leverage opt positive number leverage param, venue-clamped, OR entry["leverage"]
|
|
25
|
+
valid_for_seconds opt positive int (TTL override) valid_for_seconds param OR entry["valid_for_seconds"]
|
|
26
|
+
signal_type opt non-empty string signal_type param OR entry["signal_type"]
|
|
27
|
+
data opt dict, validated vs signal_data_schema {score, reasons} + entry["data"] merge (dynamic)
|
|
28
|
+
signal_id IGNORED scaffold owns the dedup key (author cannot set) n/a (never emitted)
|
|
29
|
+
|
|
30
|
+
Static values come from params; DYNAMIC per-asset values (the corpus emits a per-signal,
|
|
31
|
+
venue-clamped leverage and a per-thesis data block) come from the score_map `selected`
|
|
32
|
+
entry a pure_fn scorer produces, and from the live venue-max carried on the optional
|
|
33
|
+
`instruments` input port. Every field above is now expressible — full parity with a
|
|
34
|
+
hand-written scanner's signal envelope.
|
|
35
|
+
================================================================================
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from typing import Any, Optional
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _clamp_leverage(desired: Any, venue_max: Any):
|
|
44
|
+
"""Clamp desired leverage to an instrument's venue max — ported VERBATIM from the
|
|
45
|
+
corpus ``clamp_leverage`` helper (byte-identical in thesis-fund/main/scanners/scoring.py
|
|
46
|
+
and magpie/pre_listing/scanners/scoring.py).
|
|
47
|
+
|
|
48
|
+
Fallback semantics (must match the corpus): a ``venue_max`` that is None / non-numeric /
|
|
49
|
+
<= 0 (i.e. no usable venue metadata) falls back to the *desired* leverage — the signal is
|
|
50
|
+
still emitted at the requested leverage, never dropped and never clamped to 1. The result
|
|
51
|
+
is a whole-number leverage (the corpus does ``int(...)`` on both sides), floored at 1.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
venue = int(venue_max)
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
venue = int(desired)
|
|
57
|
+
if venue <= 0:
|
|
58
|
+
venue = int(desired)
|
|
59
|
+
return max(1, min(int(desired), venue))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _venue_max_map(instruments: Any) -> dict:
|
|
63
|
+
"""Project instrument_list records ([{name, max_leverage, ...}]) into an upper-cased
|
|
64
|
+
``{name: max_leverage}`` lookup — the composer analogue of thesis-fund's
|
|
65
|
+
``_get_universe_meta`` (name -> max_leverage from market_list_instruments)."""
|
|
66
|
+
out: dict = {}
|
|
67
|
+
for rec in instruments or []:
|
|
68
|
+
if isinstance(rec, dict) and rec.get("name"):
|
|
69
|
+
out[str(rec["name"]).upper()] = rec.get("max_leverage")
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def signal_envelope(
|
|
74
|
+
selected: dict,
|
|
75
|
+
equity=None,
|
|
76
|
+
*,
|
|
77
|
+
margin_pct: float,
|
|
78
|
+
leverage: float,
|
|
79
|
+
clamp_leverage_to_venue: bool,
|
|
80
|
+
instruments: Optional[list] = None,
|
|
81
|
+
valid_for_seconds: int = 0,
|
|
82
|
+
signal_type: str = "",
|
|
83
|
+
) -> list:
|
|
84
|
+
"""Map selected candidates to the scan() signal list. Port: signals.
|
|
85
|
+
|
|
86
|
+
``selected`` = ``{asset: {score, direction, reasons, ...}}``. A pure_fn scorer may attach
|
|
87
|
+
per-asset OVERRIDES on each entry (``marginPct``, ``leverage``, ``valid_for_seconds``,
|
|
88
|
+
``signal_type``, ``data``); when absent the corresponding param supplies the static value —
|
|
89
|
+
this is what gives an emitted strategy full parity with a hand-written scanner.
|
|
90
|
+
|
|
91
|
+
``equity`` (optional account scalar) is accepted for wiring parity; flat-margin strategies
|
|
92
|
+
(stag/hawk) do not consume it. ``instruments`` (optional) carries the live venue board
|
|
93
|
+
(instrument_list records); when ``clamp_leverage_to_venue`` is True AND ``instruments`` is
|
|
94
|
+
wired, each signal's leverage is clamped to that instrument's venue max exactly as the
|
|
95
|
+
corpus ``clamp_leverage`` does. Without an ``instruments`` wire the clamp is a no-op (the
|
|
96
|
+
requested leverage passes through unchanged — stag/hawk/thesis_fund/magpie today), so
|
|
97
|
+
existing graphs are byte-unchanged.
|
|
98
|
+
"""
|
|
99
|
+
venue_max = _venue_max_map(instruments) if instruments is not None else None
|
|
100
|
+
signals: list = []
|
|
101
|
+
for asset in sorted(selected):
|
|
102
|
+
entry = selected[asset]
|
|
103
|
+
|
|
104
|
+
sig_margin = entry.get("marginPct", margin_pct)
|
|
105
|
+
sig_leverage = entry.get("leverage", leverage)
|
|
106
|
+
# Real venue clamp: only bites when the caller wired the live board (venue_max is not
|
|
107
|
+
# None). Corpus clamp_leverage semantics + fallback (see _clamp_leverage docstring).
|
|
108
|
+
if clamp_leverage_to_venue and venue_max is not None:
|
|
109
|
+
sig_leverage = _clamp_leverage(sig_leverage, venue_max.get(str(asset).upper()))
|
|
110
|
+
|
|
111
|
+
data: dict = {
|
|
112
|
+
"score": entry.get("score", 0),
|
|
113
|
+
"reasons": entry.get("reasons", []),
|
|
114
|
+
}
|
|
115
|
+
# Per-asset data enrichment: a pure_fn scorer can carry a `data` block on the entry
|
|
116
|
+
# (the corpus packs trend4h/rsi/own24h/... into signal.data). Merged over the base
|
|
117
|
+
# {score, reasons}; absent for stag/hawk/thesis_fund/magpie -> byte-unchanged.
|
|
118
|
+
extra = entry.get("data")
|
|
119
|
+
if isinstance(extra, dict):
|
|
120
|
+
for k in sorted(extra):
|
|
121
|
+
data[k] = extra[k]
|
|
122
|
+
|
|
123
|
+
sig: dict = {
|
|
124
|
+
"asset": asset,
|
|
125
|
+
"direction": entry.get("direction", "LONG"),
|
|
126
|
+
"marginPct": sig_margin,
|
|
127
|
+
"leverage": sig_leverage,
|
|
128
|
+
"data": data,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# valid_for_seconds (TTL override) and signal_type are OPTIONAL scaffold-recognized
|
|
132
|
+
# keys: emitted only when set (per-asset override wins over the static param) so an
|
|
133
|
+
# unset field never appears on the wire (matches the scaffold's "absent -> use default").
|
|
134
|
+
vfs = entry.get("valid_for_seconds", valid_for_seconds)
|
|
135
|
+
if isinstance(vfs, int) and not isinstance(vfs, bool) and vfs > 0:
|
|
136
|
+
sig["valid_for_seconds"] = vfs
|
|
137
|
+
st = entry.get("signal_type", signal_type)
|
|
138
|
+
if isinstance(st, str) and st:
|
|
139
|
+
sig["signal_type"] = st
|
|
140
|
+
|
|
141
|
+
signals.append(sig)
|
|
142
|
+
return signals
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""filters — pure asset_list -> asset_list narrowing primitives.
|
|
2
|
+
|
|
3
|
+
Both preserve input order and compare case-insensitively (assets are upper-cased in
|
|
4
|
+
the corpus). Ported from the held / recently-signaled pre-fetch filters present in
|
|
5
|
+
all five corpus packages (e.g. stag ``scan.scan`` held + recent skip).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def held_asset_exclusion(*, candidates: list, held: list) -> list:
|
|
12
|
+
"""Drop candidates already held in the account. Port: kept (asset_list)."""
|
|
13
|
+
held_set = {str(h).upper() for h in held}
|
|
14
|
+
return [a for a in candidates if str(a).upper() not in held_set]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def recent_signal_ttl_dedup(*, candidates: list, recent: list) -> list:
|
|
18
|
+
"""Drop candidates signaled within the dedup TTL. Port: kept (asset_list).
|
|
19
|
+
|
|
20
|
+
``recent`` is the within-TTL exclusion set produced by ttl_dedup_state.
|
|
21
|
+
"""
|
|
22
|
+
recent_set = {str(a).upper() for a in recent}
|
|
23
|
+
return [a for a in candidates if str(a).upper() not in recent_set]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _f(v, d: float = 0.0) -> float:
|
|
27
|
+
try:
|
|
28
|
+
return float(v if v is not None else d)
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
return d
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def classifier_predicate(*, candidates: list, max_funding_abs: float,
|
|
34
|
+
max_leverage: float, exclude_delisted: bool) -> list:
|
|
35
|
+
"""Gate instrument-record membership on venue-field predicates. Port: kept (asset_list).
|
|
36
|
+
|
|
37
|
+
Ported from magpie ``scoring.is_ipop`` / ``ipop_passes_universe`` (the funding-signature
|
|
38
|
+
portion): keep a candidate iff (not delisted, when excluded) AND ``|funding| <=
|
|
39
|
+
max_funding_abs`` AND ``max_leverage <= max_leverage``. Operates on the instrument
|
|
40
|
+
RECORDS from instrument_list and PRESERVES them (so it can chain into universe_discover);
|
|
41
|
+
a bare-name candidate carrying no venue fields passes the (0-valued) numeric gates, i.e.
|
|
42
|
+
the generalized identity classifier. Input order is preserved.
|
|
43
|
+
"""
|
|
44
|
+
out: list = []
|
|
45
|
+
for inst in candidates or []:
|
|
46
|
+
rec = inst if isinstance(inst, dict) else {"name": inst}
|
|
47
|
+
if exclude_delisted and rec.get("is_delisted"):
|
|
48
|
+
continue
|
|
49
|
+
if abs(_f(rec.get("funding", 0))) > max_funding_abs:
|
|
50
|
+
continue
|
|
51
|
+
lev = _f(rec.get("max_leverage", 0))
|
|
52
|
+
if lev and lev > max_leverage:
|
|
53
|
+
continue
|
|
54
|
+
out.append(inst)
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def live_instrument_validation(*, candidates: list, instruments: list) -> list:
|
|
59
|
+
"""Keep only candidates present on the live instrument board. Port: kept (asset_list).
|
|
60
|
+
|
|
61
|
+
Ported from thesis-fund's "only score names live on the instrument board" check
|
|
62
|
+
(``meta.get(asset) is None -> skip``): intersect candidate names with the live board
|
|
63
|
+
from instrument_list (case-insensitive), preserving candidate order/case.
|
|
64
|
+
"""
|
|
65
|
+
names: set = set()
|
|
66
|
+
for inst in instruments or []:
|
|
67
|
+
rec = inst if isinstance(inst, dict) else {"name": inst}
|
|
68
|
+
nm = rec.get("name")
|
|
69
|
+
if isinstance(nm, str):
|
|
70
|
+
names.add(nm.upper())
|
|
71
|
+
return [c for c in (candidates or []) if str(c).upper() in names]
|