@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716013009

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 (94) 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 +287 -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 +124 -0
  9. package/dist/composer/AUTHORING.md +233 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +49 -0
  12. package/dist/composer/authoring/answers.py +226 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +25 -0
  14. package/dist/composer/authoring/archetypes/_common.py +218 -0
  15. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  16. package/dist/composer/authoring/archetypes/skeleton.py +54 -0
  17. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  18. package/dist/composer/authoring/archetypes/trend_momentum.py +104 -0
  19. package/dist/composer/authoring/builder.py +191 -0
  20. package/dist/composer/authoring/emit.py +264 -0
  21. package/dist/composer/authoring/gaps.py +115 -0
  22. package/dist/composer/authoring/generate.py +131 -0
  23. package/dist/composer/authoring/presets.py +168 -0
  24. package/dist/composer/authoring/runtime_emit.py +266 -0
  25. package/dist/composer/authoring/strategy_manifest.py +94 -0
  26. package/dist/composer/cli.py +355 -0
  27. package/dist/composer/compiler/__init__.py +22 -0
  28. package/dist/composer/compiler/compile.py +550 -0
  29. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  30. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  31. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  32. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  33. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  34. package/dist/composer/generated/README.md +22 -0
  35. package/dist/composer/generated/catalog.md +84 -0
  36. package/dist/composer/generated/describe.md +703 -0
  37. package/dist/composer/generated/graph.schema.json +1203 -0
  38. package/dist/composer/primitives/__init__.py +239 -0
  39. package/dist/composer/primitives/_candles.py +76 -0
  40. package/dist/composer/primitives/adapter.py +120 -0
  41. package/dist/composer/primitives/emitters.py +142 -0
  42. package/dist/composer/primitives/filters.py +71 -0
  43. package/dist/composer/primitives/indicators.py +203 -0
  44. package/dist/composer/primitives/rankers.py +40 -0
  45. package/dist/composer/primitives/scorers.py +100 -0
  46. package/dist/composer/primitives/sources.py +405 -0
  47. package/dist/composer/primitives/stateful.py +52 -0
  48. package/dist/composer/pure_fn/__init__.py +34 -0
  49. package/dist/composer/pure_fn/checks.py +39 -0
  50. package/dist/composer/pure_fn/models.py +177 -0
  51. package/dist/composer/pure_fn/purity.py +311 -0
  52. package/dist/composer/pure_fn/runtests.py +155 -0
  53. package/dist/composer/registry/__init__.py +40 -0
  54. package/dist/composer/registry/generate.py +422 -0
  55. package/dist/composer/registry/models.py +331 -0
  56. package/dist/composer/registry/nodes.py +673 -0
  57. package/dist/composer/requirements.txt +21 -0
  58. package/dist/composer/templates/__init__.py +13 -0
  59. package/dist/composer/templates/scan.py.j2 +31 -0
  60. package/dist/composer/validator/__init__.py +29 -0
  61. package/dist/composer/validator/desugar.py +133 -0
  62. package/dist/composer/validator/errors.py +191 -0
  63. package/dist/composer/validator/graph_checks.py +348 -0
  64. package/dist/composer/validator/loader.py +110 -0
  65. package/dist/composer/validator/schema_checks.py +147 -0
  66. package/dist/composer/validator/validate.py +86 -0
  67. package/dist/composer/verbs/__init__.py +33 -0
  68. package/dist/composer/verbs/check.py +193 -0
  69. package/dist/composer/verbs/deploy.py +223 -0
  70. package/dist/composer/verbs/docs.py +30 -0
  71. package/dist/composer/verbs/runtime_validate.py +262 -0
  72. package/dist/composer/verbs/smoke.py +115 -0
  73. package/dist/composer/verbs/status.py +205 -0
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +2 -9
  76. package/dist/index.js.map +1 -1
  77. package/dist/runtime-api/config.d.ts +2 -2
  78. package/dist/runtime-api/config.js +4 -4
  79. package/dist/runtime-api/config.js.map +1 -1
  80. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  81. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  82. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  83. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  84. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  85. package/dist/runtime-api/intake/intake-mount.js +2 -8
  86. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  87. package/dist/skills-manager/manifest.js +1 -1
  88. package/dist/skills-manager/manifest.js.map +1 -1
  89. package/dist/skills-manager/skills-manifest.json +1 -1
  90. package/package.json +4 -2
  91. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  92. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  93. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  94. package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
@@ -0,0 +1,239 @@
1
+ """primitives — the runtime-owned pure Python implementing the registry nodes (D2 §1).
2
+
3
+ Primitive nodes are the parameterized building blocks where all I/O and effects live
4
+ (sources, indicators, filters, rankers, emitters) plus the stateful TTL-dedup variant
5
+ whose state is a typed in/out port pair compiled onto ``ctx.state`` (D2). Their
6
+ implementations live here so both the compiled ``scan.py`` and, later, hand-written
7
+ tier-3 scanners can import them; the emitted scanner reaches this package through the
8
+ scaffold ``sys.path`` (recorded POC decision: no packaging yet, ``senpi_ta`` versioning
9
+ stays open post-POC).
10
+
11
+ Slice-3 scope: real implementations for every node the stag AND hawk graphs use —
12
+ 3 sources, 7 indicators, 2 filters, 1 scorer, 1 ranker, 1 emitter, 1 stateful = 16.
13
+ Each is a pure function (or, for the sources, a pure normalizer paired with the thin
14
+ ``adapter`` that does the I/O) over plain data structures. Semantics are ported from
15
+ the corpus originals (cited in each module); per-primitive golden vectors under
16
+ python-tests/composer define correctness.
17
+
18
+ ``IMPLEMENTATIONS`` is the machine-checkable bridge between the registry declarations
19
+ and these functions: the registry<->implementation signature-agreement test asserts,
20
+ for every implemented node, that the callable's signature and the params/ports it
21
+ consumes match the registry NodeDef exactly.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import Callable, Optional
28
+
29
+ from . import emitters, filters, indicators, rankers, scorers, sources, stateful
30
+ from .adapter import McpSourceAdapter, make_adapter
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ImplSpec:
35
+ """How one registry node maps onto its Python implementation.
36
+
37
+ fn_args — the callable's exact ordered parameter names (pins the signature).
38
+ param_args — registry param names the callable consumes.
39
+ adapter_params — registry param names consumed by the adapter / emitted code, not fn.
40
+ port_args — registry (non-state) input-port names the callable consumes.
41
+ out_ports — registry (non-state) output-port names the callable returns, in order.
42
+ write_fn/args — the WRITE half of a split stateful node (epilogue), if any.
43
+ """
44
+
45
+ node: str
46
+ fn: Callable
47
+ fn_args: tuple = ()
48
+ param_args: tuple = ()
49
+ adapter_params: tuple = ()
50
+ port_args: tuple = ()
51
+ out_ports: tuple = ()
52
+ write_fn: Optional[Callable] = None
53
+ write_args: tuple = ()
54
+
55
+
56
+ IMPLEMENTATIONS: dict[str, ImplSpec] = {
57
+ # ---- sources (pure normalizer + adapter-fetched raw) --------------------
58
+ "candles_fetch": ImplSpec(
59
+ node="candles_fetch", fn=sources.candles_fetch,
60
+ fn_args=("raw_by_asset", "intervals"),
61
+ param_args=("intervals",),
62
+ adapter_params=("whitelist", "include_funding", "include_asset_context"),
63
+ port_args=(), out_ports=("candles",),
64
+ ),
65
+ "clearinghouse_state": ImplSpec(
66
+ node="clearinghouse_state", fn=sources.clearinghouse_state,
67
+ fn_args=("raw",), out_ports=("held", "equity"),
68
+ ),
69
+ "sm_lean": ImplSpec(
70
+ node="sm_lean", fn=sources.sm_lean,
71
+ fn_args=("raw", "assets", "agree_threshold"),
72
+ param_args=("agree_threshold",), port_args=("assets",), out_ports=("lean",),
73
+ ),
74
+ "instrument_list": ImplSpec(
75
+ node="instrument_list", fn=sources.instrument_list,
76
+ fn_args=("raw",), adapter_params=("dex",), out_ports=("instruments",),
77
+ ),
78
+ "universe_discover": ImplSpec(
79
+ node="universe_discover", fn=sources.universe_discover,
80
+ fn_args=("instruments", "min_day_volume", "max_leverage_cap", "name_prefix"),
81
+ param_args=("min_day_volume", "max_leverage_cap", "name_prefix"),
82
+ port_args=("instruments",), out_ports=("universe",),
83
+ ),
84
+ "own_return_24h": ImplSpec(
85
+ node="own_return_24h", fn=sources.own_return_24h,
86
+ fn_args=("candles",), port_args=("candles",), out_ports=("return_24h",),
87
+ ),
88
+ # ---- batch C: world-coverage sources ------------------------------------
89
+ "market_prices_fetch": ImplSpec(
90
+ node="market_prices_fetch", fn=sources.market_prices,
91
+ fn_args=("raw", "assets"),
92
+ adapter_params=("dex",), port_args=("assets",), out_ports=("prices",),
93
+ ),
94
+ "strategy_list_fetch": ImplSpec(
95
+ node="strategy_list_fetch", fn=sources.strategy_list,
96
+ fn_args=("raw",), adapter_params=("status", "strategy_type"),
97
+ out_ports=("strategies",),
98
+ ),
99
+ "open_orders_fetch": ImplSpec(
100
+ node="open_orders_fetch", fn=sources.open_orders,
101
+ fn_args=("raw",), out_ports=("orders",),
102
+ ),
103
+ "trader_history_fetch": ImplSpec(
104
+ node="trader_history_fetch", fn=sources.trader_history,
105
+ fn_args=("raw",), adapter_params=("trader_address", "limit"),
106
+ out_ports=("closed_positions",),
107
+ ),
108
+ "asset_trading_limits_fetch": ImplSpec(
109
+ node="asset_trading_limits_fetch", fn=sources.asset_trading_limits,
110
+ fn_args=("raw_by_asset",), adapter_params=("dex",),
111
+ out_ports=("max_leverage",),
112
+ ),
113
+ "strategy_pnl_snapshot": ImplSpec(
114
+ node="strategy_pnl_snapshot", fn=sources.strategy_pnl_snapshot,
115
+ fn_args=("raw",), adapter_params=("period", "drawdown_reset_on_day_rollover"),
116
+ out_ports=("today_pnl", "today_account_value", "today_drawdown"),
117
+ ),
118
+ "trader_state_fetch": ImplSpec(
119
+ node="trader_state_fetch", fn=sources.trader_state,
120
+ fn_args=("raw",), adapter_params=("trader_address",),
121
+ out_ports=("held", "equity"),
122
+ ),
123
+ # ---- indicators ---------------------------------------------------------
124
+ "sma": ImplSpec(
125
+ node="sma", fn=indicators.sma,
126
+ fn_args=("candles", "period", "interval"),
127
+ param_args=("period", "interval"), port_args=("candles",),
128
+ out_ports=("value", "above"),
129
+ ),
130
+ "pct_change": ImplSpec(
131
+ node="pct_change", fn=indicators.pct_change,
132
+ fn_args=("candles", "lookback", "interval"),
133
+ param_args=("lookback", "interval"), port_args=("candles",), out_ports=("pct",),
134
+ ),
135
+ "recent_high_bars_ago": ImplSpec(
136
+ node="recent_high_bars_ago", fn=indicators.recent_high_bars_ago,
137
+ fn_args=("candles", "window", "interval"),
138
+ param_args=("window", "interval"), port_args=("candles",), out_ports=("bars_ago",),
139
+ ),
140
+ "volume_surge_ratio": ImplSpec(
141
+ node="volume_surge_ratio", fn=indicators.volume_surge_ratio,
142
+ fn_args=("candles", "recent_window", "baseline_window", "interval"),
143
+ param_args=("recent_window", "baseline_window", "interval"),
144
+ port_args=("candles",), out_ports=("ratio",),
145
+ ),
146
+ "acceleration": ImplSpec(
147
+ node="acceleration", fn=indicators.acceleration,
148
+ fn_args=("candles", "short_window", "long_window", "interval"),
149
+ param_args=("short_window", "long_window", "interval"),
150
+ port_args=("candles",), out_ports=("accelerating",),
151
+ ),
152
+ "trend_structure": ImplSpec(
153
+ node="trend_structure", fn=indicators.trend_structure,
154
+ fn_args=("candles", "lookback", "interval"),
155
+ param_args=("lookback", "interval"), port_args=("candles",), out_ports=("direction",),
156
+ ),
157
+ "breakout_detect": ImplSpec(
158
+ node="breakout_detect", fn=indicators.breakout_detect,
159
+ fn_args=("candles", "window", "interval"),
160
+ param_args=("window", "interval"), port_args=("candles",), out_ports=("direction",),
161
+ ),
162
+ "rsi": ImplSpec(
163
+ node="rsi", fn=indicators.rsi,
164
+ fn_args=("candles", "period", "interval"),
165
+ param_args=("period", "interval"), port_args=("candles",), out_ports=("rsi",),
166
+ ),
167
+ # ---- filters ------------------------------------------------------------
168
+ "held_asset_exclusion": ImplSpec(
169
+ node="held_asset_exclusion", fn=filters.held_asset_exclusion,
170
+ fn_args=("candidates", "held"), port_args=("candidates", "held"), out_ports=("kept",),
171
+ ),
172
+ "recent_signal_ttl_dedup": ImplSpec(
173
+ node="recent_signal_ttl_dedup", fn=filters.recent_signal_ttl_dedup,
174
+ fn_args=("candidates", "recent"), port_args=("candidates", "recent"), out_ports=("kept",),
175
+ ),
176
+ "classifier_predicate": ImplSpec(
177
+ node="classifier_predicate", fn=filters.classifier_predicate,
178
+ fn_args=("candidates", "max_funding_abs", "max_leverage", "exclude_delisted"),
179
+ param_args=("max_funding_abs", "max_leverage", "exclude_delisted"),
180
+ port_args=("candidates",), out_ports=("kept",),
181
+ ),
182
+ "live_instrument_validation": ImplSpec(
183
+ node="live_instrument_validation", fn=filters.live_instrument_validation,
184
+ fn_args=("candidates", "instruments"),
185
+ port_args=("candidates", "instruments"), out_ports=("kept",),
186
+ ),
187
+ # ---- scorer -------------------------------------------------------------
188
+ "hard_gates_all": ImplSpec(
189
+ node="hard_gates_all", fn=scorers.hard_gates_all,
190
+ fn_args=("features", "required_gates", "weights", "min_score"),
191
+ param_args=("required_gates", "weights", "min_score"),
192
+ port_args=("features",), out_ports=("scores",),
193
+ ),
194
+ # ---- ranker -------------------------------------------------------------
195
+ "emit_single_best": ImplSpec(
196
+ node="emit_single_best", fn=rankers.emit_single_best,
197
+ fn_args=("scores", "min_score"),
198
+ param_args=("min_score",), port_args=("scores",), out_ports=("selected",),
199
+ ),
200
+ "rank_pool_cap_to_open_slots": ImplSpec(
201
+ node="rank_pool_cap_to_open_slots", fn=rankers.rank_pool_cap_to_open_slots,
202
+ fn_args=("scores", "held", "max_slots", "min_score"),
203
+ param_args=("max_slots", "min_score"), port_args=("scores", "held"),
204
+ out_ports=("selected",),
205
+ ),
206
+ # ---- emitter ------------------------------------------------------------
207
+ "signal_envelope": ImplSpec(
208
+ node="signal_envelope", fn=emitters.signal_envelope,
209
+ fn_args=("selected", "equity", "margin_pct", "leverage", "clamp_leverage_to_venue",
210
+ "instruments", "valid_for_seconds", "signal_type"),
211
+ param_args=("margin_pct", "leverage", "clamp_leverage_to_venue",
212
+ "valid_for_seconds", "signal_type"),
213
+ port_args=("selected", "equity", "instruments"), out_ports=("signals",),
214
+ ),
215
+ # ---- stateful (split read/write halves) ---------------------------------
216
+ "ttl_dedup_state": ImplSpec(
217
+ node="ttl_dedup_state", fn=stateful.ttl_dedup_state_read,
218
+ fn_args=("state", "ttl_seconds", "prune_factor", "now"),
219
+ param_args=("ttl_seconds", "prune_factor"),
220
+ port_args=(), out_ports=("recent",),
221
+ write_fn=stateful.ttl_dedup_state_write,
222
+ write_args=("state", "working_map", "emitted", "now"),
223
+ ),
224
+ }
225
+
226
+ __all__ = [
227
+ "sources",
228
+ "indicators",
229
+ "filters",
230
+ "scorers",
231
+ "rankers",
232
+ "emitters",
233
+ "stateful",
234
+ "adapter",
235
+ "McpSourceAdapter",
236
+ "make_adapter",
237
+ "ImplSpec",
238
+ "IMPLEMENTATIONS",
239
+ ]
@@ -0,0 +1,76 @@
1
+ """_candles — shared candle-field coercion helpers for the indicator primitives.
2
+
3
+ These reproduce the dual-key float coercion used verbatim across the corpus
4
+ originals (``_f`` / ``_close`` / ``_high`` / ``_low`` / ``_vol``); see
5
+ ``/Users/duncan/Senpi/skills/strategies/hawk/main/scanners/scoring.py`` and
6
+ ``/Users/duncan/Senpi/skills/strategies/stag/main/scanners/scoring.py``. A candle
7
+ is a dict carrying either the long key (``close``) or the short key (``c``); the
8
+ list branch ``[t, o, h, l, c, v]`` is defensive and never fires on dict candles.
9
+
10
+ The goldens WE author (python-tests/composer) define correctness — these helpers
11
+ follow the originals' coercion so the compiled graphs behave the same way on the
12
+ same candle series, but bit-level parity with the originals is not the oracle.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+
20
+ def _f(v: Any, d: float = 0.0) -> float:
21
+ """Float coercion (corpus ``scoring._f``): non-numeric / None -> default."""
22
+ try:
23
+ return float(v if v is not None else d)
24
+ except (TypeError, ValueError):
25
+ return d
26
+
27
+
28
+ def _close(c: Any) -> float:
29
+ if isinstance(c, dict):
30
+ return _f(c.get("close", c.get("c", 0)))
31
+ if isinstance(c, (list, tuple)) and len(c) >= 5:
32
+ return _f(c[4])
33
+ return 0.0
34
+
35
+
36
+ def _high(c: Any) -> float:
37
+ if isinstance(c, dict):
38
+ return _f(c.get("high", c.get("h", 0)))
39
+ if isinstance(c, (list, tuple)) and len(c) >= 5:
40
+ return _f(c[2])
41
+ return 0.0
42
+
43
+
44
+ def _low(c: Any) -> float:
45
+ if isinstance(c, dict):
46
+ return _f(c.get("low", c.get("l", 0)))
47
+ if isinstance(c, (list, tuple)) and len(c) >= 5:
48
+ return _f(c[3])
49
+ return 0.0
50
+
51
+
52
+ def _vol(c: Any) -> float:
53
+ if isinstance(c, dict):
54
+ return _f(c.get("volume", c.get("v", c.get("vlm", 0))))
55
+ if isinstance(c, (list, tuple)) and len(c) >= 6:
56
+ return _f(c[5])
57
+ return 0.0
58
+
59
+
60
+ def _closes(series: list) -> list[float]:
61
+ return [_close(c) for c in series]
62
+
63
+
64
+ def _pct_change(closes: list[float], lookback: int):
65
+ """% change of the latest close vs the close ``lookback`` bars ago.
66
+
67
+ Verbatim shape from stag ``scoring.pct_change`` (cited file above): None on
68
+ insufficient data or a non-positive reference price.
69
+ """
70
+ if not closes or len(closes) <= lookback:
71
+ return None
72
+ ref = closes[-(lookback + 1)]
73
+ latest = closes[-1]
74
+ if ref is None or ref <= 0:
75
+ return None
76
+ return ((latest - ref) / ref) * 100.0
@@ -0,0 +1,120 @@
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
+
20
+ class McpSourceAdapter:
21
+ """Wraps a scaffold ``ctx`` and exposes one read method per corpus MCP tool."""
22
+
23
+ def __init__(self, ctx: Any) -> None:
24
+ self._ctx = ctx
25
+
26
+ def _read(self, name: str, args: dict):
27
+ mcp = getattr(self._ctx, "senpi_mcp", None)
28
+ if mcp is None:
29
+ return None
30
+ try:
31
+ return mcp.call_tool(name, args)
32
+ except Exception as exc: # noqa: BLE001 — a read error must not roll back the tick
33
+ print(f"[composer.adapter] {name} read failed: {exc!r}", file=sys.stderr)
34
+ return None
35
+
36
+ def asset_data(self, asset: str, intervals: list, *, include_funding: bool = False,
37
+ include_asset_context: bool = False, dex: str = "") -> Any:
38
+ args = {
39
+ "asset": asset,
40
+ "candle_intervals": list(intervals),
41
+ "include_funding": include_funding,
42
+ "include_order_book": False,
43
+ }
44
+ if include_asset_context:
45
+ args["include_asset_context"] = True
46
+ if dex:
47
+ args["dex"] = dex
48
+ return self._read("market_get_asset_data", args)
49
+
50
+ def clearinghouse(self) -> Any:
51
+ wallet = getattr(self._ctx, "wallet", "") or ""
52
+ return self._read("strategy_get_clearinghouse_state", {"strategy_wallet": wallet})
53
+
54
+ def leaderboard_markets(self) -> Any:
55
+ return self._read("leaderboard_get_markets", {})
56
+
57
+ def instruments(self, *, dex: str = "") -> Any:
58
+ return self._read("market_list_instruments", {"dex": dex})
59
+
60
+ # -------------------------------------------------- world-coverage reads (batch C)
61
+
62
+ def market_prices(self, *, dex: str = "") -> Any:
63
+ """market_get_prices — all latest prices for a venue (omitting `assets` returns
64
+ the full map; the normalizer filters to the wired universe)."""
65
+ return self._read("market_get_prices", {"dex": dex})
66
+
67
+ def strategy_list(self, *, status: str = "", strategy_type: str = "") -> Any:
68
+ """strategy_list — strategy-metadata rows, optionally filtered by status / type."""
69
+ args: dict = {}
70
+ if status:
71
+ args["status"] = status
72
+ if strategy_type:
73
+ args["strategy_type"] = strategy_type
74
+ return self._read("strategy_list", args)
75
+
76
+ def open_orders(self) -> Any:
77
+ """strategy_get_open_orders for the strategy's own wallet (like clearinghouse)."""
78
+ wallet = getattr(self._ctx, "wallet", "") or ""
79
+ return self._read("strategy_get_open_orders", {"strategy_wallet": wallet})
80
+
81
+ def trader_history(self, *, trader_address: str, limit: int = 100) -> Any:
82
+ """discovery_get_trader_history — closed-trade history for an arbitrary trader."""
83
+ return self._read(
84
+ "discovery_get_trader_history",
85
+ {"trader_address": trader_address, "latest": True, "limit": limit},
86
+ )
87
+
88
+ def asset_trading_limits(self, coin: str, *, dex: str = "") -> Any:
89
+ """strategy_get_asset_trading_limits — single-asset venue limit for the own wallet
90
+ (fanned out per-asset by the emitted comprehension, like asset_data)."""
91
+ wallet = getattr(self._ctx, "wallet", "") or ""
92
+ args = {"strategy_wallet": wallet, "coin": coin}
93
+ if dex:
94
+ args["dex"] = dex
95
+ return self._read("strategy_get_asset_trading_limits", args)
96
+
97
+ def pnl_snapshot(self, *, period: str = "", drawdown_reset_on_day_rollover: bool = False) -> Any:
98
+ """strategy_get_pnl_and_account_value_history for the own wallet."""
99
+ wallet = getattr(self._ctx, "wallet", "") or ""
100
+ args: dict = {"strategy_wallet": wallet}
101
+ if period:
102
+ args["period"] = period
103
+ if drawdown_reset_on_day_rollover:
104
+ args["drawdown_reset_on_day_rollover"] = True
105
+ return self._read("strategy_get_pnl_and_account_value_history", args)
106
+
107
+ def trader_state(self, *, trader_address: str) -> Any:
108
+ """discovery_get_trader_state — an arbitrary wallet's current book (clearinghouse
109
+ twin). The MCP arg is a BATCH (`trader_addresses`); we read the single wired wallet."""
110
+ return self._read(
111
+ "discovery_get_trader_state",
112
+ {"trader_addresses": [trader_address], "latest": True},
113
+ )
114
+
115
+
116
+ def make_adapter(ctx: Any) -> McpSourceAdapter:
117
+ """Factory the compiled scan.py calls. A test may monkeypatch this module attribute
118
+ to inject a fully synthetic adapter, but the default (a real ctx + fake senpi_mcp)
119
+ is the primary stubbing seam."""
120
+ 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]