@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,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]