@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,739 @@
1
+ """compile — the deterministic materializer: graph -> straight-line scan.py (D5, D8).
2
+
3
+ Pipeline (HLD D8, no IR, no optimization):
4
+
5
+ validate (reuse composer.validator) -> a hard gate; refuse to compile an invalid
6
+ graph, surfacing the teaching-error report.
7
+ load + desugar -> canonical nodes + edges (the graph IS the IR).
8
+ topo-sort (non-stateful nodes) -> a stable straight-line order.
9
+ Jinja2 template walk -> a single self-contained-except-primitives
10
+ scan.py on the external_scanner contract.
11
+
12
+ Determinism is a hard requirement (the recompile oracle): stable node iteration
13
+ (topo order with an id tie-break), sorted keys in every emitted literal, no timestamps
14
+ in the artifact. Same graph -> byte-identical scan.py.
15
+
16
+ Stateful nodes are compiled onto ctx.state and SPLIT into a prologue (read prior-tick
17
+ state -> the `recent` exclusion set) and an epilogue (append this tick's emitted
18
+ signals). Data-source nodes compile onto the ctx MCP surface via the thin
19
+ ``primitives.adapter`` (the golden-test stubbing seam). Emitted code is readable:
20
+ every block is commented with its node id.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import ast
26
+ from pathlib import Path
27
+
28
+ from jinja2 import Environment, FileSystemLoader
29
+
30
+ from composer.pure_fn import is_pure_fn_entry, parse_pure_fn
31
+ from composer.registry import NODES_BY_NAME, NodeKind, PortType
32
+ from composer.validator import validate_text
33
+ from composer.validator.desugar import desugar, has_pipeline
34
+ from composer.validator.loader import load_graph
35
+
36
+ _TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
37
+
38
+
39
+ class CompileError(Exception):
40
+ """Raised when a graph cannot be compiled (invalid graph, or a structural gap)."""
41
+
42
+
43
+ # --------------------------------------------------------------------------- literals
44
+
45
+ def _pyliteral(value) -> str:
46
+ """Deterministic Python-literal rendering: dict keys sorted, tuples -> lists."""
47
+ if isinstance(value, dict):
48
+ items = ", ".join(f"{_pyliteral(k)}: {_pyliteral(v)}" for k, v in sorted(value.items()))
49
+ return "{" + items + "}"
50
+ if isinstance(value, (list, tuple)):
51
+ return "[" + ", ".join(_pyliteral(v) for v in value) + "]"
52
+ if isinstance(value, bool):
53
+ return "True" if value else "False"
54
+ return repr(value)
55
+
56
+
57
+ def _sanitize(node_id: str) -> str:
58
+ return "".join(ch if (ch.isalnum() or ch == "_") else "_" for ch in node_id)
59
+
60
+
61
+ def _var(node_id: str, port: str) -> str:
62
+ return f"{_sanitize(node_id)}__{port}"
63
+
64
+
65
+ # --------------------------------------------------------------------------- model
66
+
67
+ class _Graph:
68
+ """The parsed, validated graph in the shape the emitter walks."""
69
+
70
+ def __init__(self, doc: dict):
71
+ self.nodes = doc.get("nodes") or []
72
+ self.edges = doc.get("edges") or []
73
+ self.metadata = doc.get("metadata") or {}
74
+ self.id_to_type: dict[str, str] = {}
75
+ self.with_of: dict[str, dict] = {}
76
+ self.pure_fns: dict[str, object] = {} # id -> PureFnNode (open extension, D2 kind 2)
77
+ for n in self.nodes:
78
+ nid = n["id"]
79
+ if is_pure_fn_entry(n):
80
+ self.pure_fns[nid] = parse_pure_fn(n)
81
+ else:
82
+ self.id_to_type[nid] = n["node"]
83
+ self.with_of[nid] = n.get("with") or {}
84
+ # incoming[(to_id, to_port)] = [(from_id, from_port), ...]
85
+ self.incoming: dict[tuple[str, str], list[tuple[str, str]]] = {}
86
+ for e in self.edges:
87
+ fid, fport = e["from"].split(".", 1)
88
+ tid, tport = e["to"].split(".", 1)
89
+ self.incoming.setdefault((tid, tport), []).append((fid, fport))
90
+
91
+ def nodedef(self, node_id: str):
92
+ if node_id in self.pure_fns:
93
+ return self.pure_fns[node_id]
94
+ return NODES_BY_NAME[self.id_to_type[node_id]]
95
+
96
+ def param(self, node_id: str, param_name: str):
97
+ """The `with:` value for a param, else its (registry / pure_fn) default."""
98
+ if node_id in self.pure_fns:
99
+ for p in self.pure_fns[node_id].params:
100
+ if p.name == param_name:
101
+ return p.default
102
+ return None
103
+ w = self.with_of[node_id]
104
+ if param_name in w:
105
+ return w[param_name]
106
+ for p in self.nodedef(node_id).params:
107
+ if p.name == param_name:
108
+ return p.default
109
+ return None
110
+
111
+ def base_universe(self) -> list:
112
+ """The candles_fetch whitelist (there is one in the POC graphs), for the
113
+ _BASE_UNIVERSE constant and unwired asset_list defaults."""
114
+ for nid, ntype in self.id_to_type.items():
115
+ if ntype == "candles_fetch":
116
+ wl = self.param(nid, "whitelist") or ()
117
+ return [str(a).upper() for a in wl]
118
+ return []
119
+
120
+
121
+ # --------------------------------------------------------------------------- topo sort
122
+
123
+ def _topo_order(g: _Graph) -> list[str]:
124
+ """Deterministic topo order over the NON-stateful nodes.
125
+
126
+ Edges out of a stateful node carry prior-tick data (its `recent` output is
127
+ prologue-provided), and edges into a stateful node are handled in the epilogue, so
128
+ neither constrains the body order. Ties broken by node id for byte-stability.
129
+ """
130
+ body = [n["id"] for n in g.nodes if g.nodedef(n["id"]).kind != NodeKind.stateful]
131
+ body_set = set(body)
132
+ deps: dict[str, set] = {nid: set() for nid in body}
133
+ for (tid, _tport), writers in g.incoming.items():
134
+ if tid not in body_set:
135
+ continue
136
+ for fid, _fport in writers:
137
+ if fid in body_set and g.nodedef(fid).kind != NodeKind.stateful:
138
+ deps[tid].add(fid)
139
+ order: list[str] = []
140
+ done: set = set()
141
+ remaining = set(body)
142
+ while remaining:
143
+ ready = sorted(nid for nid in remaining if deps[nid] <= done)
144
+ if not ready:
145
+ raise CompileError(f"cycle among body nodes: {sorted(remaining)}")
146
+ pick = ready[0]
147
+ order.append(pick)
148
+ done.add(pick)
149
+ remaining.discard(pick)
150
+ return order
151
+
152
+
153
+ # --------------------------------------------------------------------------- inputs
154
+
155
+ def _resolve_input(g: _Graph, node_id: str, port) -> str:
156
+ """A Python expression for the value flowing into ``node_id.port.name``."""
157
+ writers = g.incoming.get((node_id, port.name), [])
158
+ if port.merge:
159
+ # merge_features keyed by SOURCE NODE ID (see scorers.merge_features).
160
+ items = ", ".join(
161
+ f"{_pyliteral(fid)}: {_var(fid, fport)}"
162
+ for fid, fport in sorted(writers)
163
+ )
164
+ return "{" + items + "}"
165
+ if writers:
166
+ fid, fport = writers[0]
167
+ return _var(fid, fport)
168
+ # unwired optional input
169
+ if port.type == PortType.asset_list:
170
+ return "base_universe"
171
+ return "None"
172
+
173
+
174
+ # --------------------------------------------------------------------------- emitters
175
+
176
+ def _kwargs(g: _Graph, node_id: str, param_names) -> str:
177
+ parts = []
178
+ for name in param_names:
179
+ parts.append(f"{name}={_pyliteral(g.param(node_id, name))}")
180
+ return ", ".join(parts)
181
+
182
+
183
+ def _emit_pure_fn(g: _Graph, node_id: str) -> list[str]:
184
+ """Materialize a pure_fn node: its declared `def` verbatim (readable, node-id
185
+ commented), then a call binding its declared outputs. The def is emitted nested inside
186
+ scan() at the node's topological position and called like any other node. Determinism
187
+ is preserved: the body is emitted byte-for-byte as authored and args are resolved in
188
+ declared port/param order.
189
+ """
190
+ pfn = g.pure_fns[node_id]
191
+ fd = pfn.function_def()
192
+ fn_name = fd.name # purity guaranteed exactly one def by the time we compile
193
+ lines = [
194
+ f"# node: {node_id} (pure_fn) — LLM-written, purity-checked "
195
+ f"(no ctx/MCP/I/O; math/statistics only)",
196
+ ]
197
+ # the authored def, verbatim (each line is indented +4 by the template -> nested).
198
+ lines.extend(pfn.fn_src.strip("\n").split("\n"))
199
+ port_exprs = [_resolve_input(g, node_id, p) for p in pfn.inputs]
200
+ param_vals = [_pyliteral(g.param(node_id, p.name)) for p in pfn.params]
201
+ args = ", ".join(port_exprs + param_vals)
202
+ outs = ", ".join(_var(node_id, p.name) for p in pfn.outputs)
203
+ lines.append(f"{outs} = {fn_name}({args})")
204
+ return lines
205
+
206
+
207
+ def _emit_body_node(g: _Graph, node_id: str) -> list[str]:
208
+ nd = g.nodedef(node_id)
209
+ if nd.kind == NodeKind.pure_fn:
210
+ return _emit_pure_fn(g, node_id)
211
+ ntype = nd.name
212
+ lines = [f"# node: {node_id} ({ntype}) — {nd.summary}"]
213
+
214
+ if ntype == "candles_fetch":
215
+ assets_input = next(p for p in nd.inputs if p.name == "assets")
216
+ assets_expr = _resolve_input(g, node_id, assets_input)
217
+ intervals = list(g.param(node_id, "intervals") or ())
218
+ inc_f = bool(g.param(node_id, "include_funding"))
219
+ inc_ac = bool(g.param(node_id, "include_asset_context"))
220
+ raw = f"_raw_{_sanitize(node_id)}"
221
+ lines.append(
222
+ f"{raw} = {{_a: src.asset_data(_a, {_pyliteral(intervals)}, "
223
+ f"include_funding={_pyliteral(inc_f)}, "
224
+ f"include_asset_context={_pyliteral(inc_ac)}) for _a in {assets_expr}}}"
225
+ )
226
+ lines.append(
227
+ f"{_var(node_id, 'candles')} = sources.candles_fetch({raw}, "
228
+ f"intervals={_pyliteral(intervals)})"
229
+ )
230
+ return lines
231
+
232
+ if ntype == "clearinghouse_state":
233
+ lines.append(
234
+ f"{_var(node_id, 'held')}, {_var(node_id, 'equity')} = "
235
+ f"sources.clearinghouse_state(src.clearinghouse())"
236
+ )
237
+ return lines
238
+
239
+ if ntype == "sm_lean":
240
+ assets_input = next(p for p in nd.inputs if p.name == "assets")
241
+ assets_expr = _resolve_input(g, node_id, assets_input)
242
+ thr = g.param(node_id, "agree_threshold")
243
+ lines.append(
244
+ f"{_var(node_id, 'lean')} = sources.sm_lean(src.leaderboard_markets(), "
245
+ f"assets={assets_expr}, agree_threshold={_pyliteral(thr)})"
246
+ )
247
+ return lines
248
+
249
+ if ntype == "instrument_list":
250
+ dex = g.param(node_id, "dex") or ""
251
+ lines.append(
252
+ f"{_var(node_id, 'instruments')} = sources.instrument_list("
253
+ f"src.instruments(dex={_pyliteral(dex)}))"
254
+ )
255
+ return lines
256
+
257
+ if ntype == "universe_discover":
258
+ insts = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "instruments"))
259
+ kw = _kwargs(g, node_id, ["min_day_volume", "max_leverage_cap", "name_prefix"])
260
+ lines.append(
261
+ f"{_var(node_id, 'universe')} = sources.universe_discover({insts}, {kw})"
262
+ )
263
+ return lines
264
+
265
+ if ntype == "own_return_24h":
266
+ candles_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candles"))
267
+ lines.append(
268
+ f"{_var(node_id, 'return_24h')} = sources.own_return_24h({candles_expr})"
269
+ )
270
+ return lines
271
+
272
+ # ---- batch C: world-coverage sources (recon §2) ---------------------------------
273
+ if ntype == "market_prices_fetch":
274
+ assets_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "assets"))
275
+ dex = g.param(node_id, "dex") or ""
276
+ lines.append(
277
+ f"{_var(node_id, 'prices')} = sources.market_prices("
278
+ f"src.market_prices(dex={_pyliteral(dex)}), assets={assets_expr})"
279
+ )
280
+ return lines
281
+
282
+ if ntype == "strategy_list_fetch":
283
+ status = g.param(node_id, "status") or ""
284
+ stype = g.param(node_id, "strategy_type") or ""
285
+ lines.append(
286
+ f"{_var(node_id, 'strategies')} = sources.strategy_list("
287
+ f"src.strategy_list(status={_pyliteral(status)}, strategy_type={_pyliteral(stype)}))"
288
+ )
289
+ return lines
290
+
291
+ if ntype == "open_orders_fetch":
292
+ lines.append(
293
+ f"{_var(node_id, 'orders')} = sources.open_orders(src.open_orders())"
294
+ )
295
+ return lines
296
+
297
+ if ntype == "trader_history_fetch":
298
+ ta = g.param(node_id, "trader_address")
299
+ limit = g.param(node_id, "limit")
300
+ lines.append(
301
+ f"{_var(node_id, 'closed_positions')} = sources.trader_history("
302
+ f"src.trader_history(trader_address={_pyliteral(ta)}, limit={_pyliteral(limit)}))"
303
+ )
304
+ return lines
305
+
306
+ if ntype == "asset_trading_limits_fetch":
307
+ assets_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "assets"))
308
+ dex = g.param(node_id, "dex") or ""
309
+ raw = f"_raw_{_sanitize(node_id)}"
310
+ lines.append(
311
+ f"{raw} = {{_a: src.asset_trading_limits(_a, dex={_pyliteral(dex)}) "
312
+ f"for _a in {assets_expr}}}"
313
+ )
314
+ lines.append(
315
+ f"{_var(node_id, 'max_leverage')} = sources.asset_trading_limits({raw})"
316
+ )
317
+ return lines
318
+
319
+ if ntype == "strategy_pnl_snapshot":
320
+ period = g.param(node_id, "period") or ""
321
+ dd = bool(g.param(node_id, "drawdown_reset_on_day_rollover"))
322
+ lines.append(
323
+ f"{_var(node_id, 'today_pnl')}, {_var(node_id, 'today_account_value')}, "
324
+ f"{_var(node_id, 'today_drawdown')} = sources.strategy_pnl_snapshot("
325
+ f"src.pnl_snapshot(period={_pyliteral(period)}, "
326
+ f"drawdown_reset_on_day_rollover={_pyliteral(dd)}))"
327
+ )
328
+ return lines
329
+
330
+ if ntype == "trader_state_fetch":
331
+ ta = g.param(node_id, "trader_address")
332
+ lines.append(
333
+ f"{_var(node_id, 'held')}, {_var(node_id, 'equity')} = "
334
+ f"sources.trader_state(src.trader_state(trader_address={_pyliteral(ta)}))"
335
+ )
336
+ return lines
337
+
338
+ # ---- wave A: remaining world-coverage sources (2026-07-16) ----------------------
339
+ if ntype == "funding_regime_fetch":
340
+ lines.append(
341
+ f"{_var(node_id, 'regime')}, {_var(node_id, 'companions')} = "
342
+ f"sources.funding_regime(src.funding_regime())"
343
+ )
344
+ return lines
345
+
346
+ if ntype == "top_traders_fetch":
347
+ kw = _kwargs(g, node_id, ["time_frame", "sort_by", "bias", "limit", "max_pages",
348
+ "open_position_filter", "activity_labels", "consistency",
349
+ "risk_labels"])
350
+ lines.append(
351
+ f"{_var(node_id, 'traders')} = sources.top_traders(src.top_traders({kw}))"
352
+ )
353
+ return lines
354
+
355
+ if ntype == "trader_states_fetch":
356
+ records_expr = _resolve_input(
357
+ g, node_id, next(p for p in nd.inputs if p.name == "traders"))
358
+ kw = _kwargs(g, node_id, ["wallets", "latest", "include_position_age",
359
+ "sort_open_positions_by", "sort_open_positions_direction"])
360
+ lines.append(
361
+ f"{_var(node_id, 'traders')} = sources.trader_states("
362
+ f"src.trader_states(records={records_expr}, {kw}))"
363
+ )
364
+ return lines
365
+
366
+ if ntype == "funding_history_fetch":
367
+ kw = _kwargs(g, node_id, ["asset", "min_annualized_pct", "min_persistence_hours"])
368
+ lines.append(
369
+ f"{_var(node_id, 'assets')} = sources.funding_history(src.funding_history({kw}))"
370
+ )
371
+ return lines
372
+
373
+ if ntype == "cross_asset_flows_fetch":
374
+ kw = _kwargs(g, node_id, ["leader_asset", "min_move_pct", "window"])
375
+ lines.append(
376
+ f"{_var(node_id, 'laggards')}, {_var(node_id, 'leader')} = "
377
+ f"sources.cross_asset_flows(src.cross_asset_flows({kw}))"
378
+ )
379
+ return lines
380
+
381
+ if ntype == "trader_positions_fetch":
382
+ tid = g.param(node_id, "trader_id")
383
+ lines.append(
384
+ f"{_var(node_id, 'positions')}, {_var(node_id, 'header')} = "
385
+ f"sources.trader_positions(src.trader_positions(trader_id={_pyliteral(tid)}))"
386
+ )
387
+ return lines
388
+
389
+ # ---- wave B: the last scanner-usable world-coverage sources (2026-07-16) --------
390
+ if ntype == "momentum_events_fetch":
391
+ kw = _kwargs(g, node_id, ["limit", "tier", "asset", "from_time", "to_time"])
392
+ lines.append(
393
+ f"{_var(node_id, 'events')}, {_var(node_id, 'header')} = "
394
+ f"sources.momentum_events(src.momentum_events({kw}))"
395
+ )
396
+ return lines
397
+
398
+ if ntype == "top_strategies_fetch":
399
+ kw = _kwargs(g, node_id, ["limit", "offset", "force_fetch", "max_pages"])
400
+ lines.append(
401
+ f"{_var(node_id, 'strategies')} = sources.top_strategies(src.top_strategies({kw}))"
402
+ )
403
+ return lines
404
+
405
+ if ntype == "arena_leaderboard_fetch":
406
+ kw = _kwargs(g, node_id, ["period_type", "period_number", "limit", "offset", "qualified"])
407
+ lines.append(
408
+ f"{_var(node_id, 'entries')} = sources.arena_leaderboard(src.arena_leaderboard({kw}))"
409
+ )
410
+ return lines
411
+
412
+ if ntype == "leaderboard_top_fetch":
413
+ kw = _kwargs(g, node_id, ["limit"])
414
+ lines.append(
415
+ f"{_var(node_id, 'traders')}, {_var(node_id, 'header')} = "
416
+ f"sources.leaderboard_top(src.leaderboard_top({kw}))"
417
+ )
418
+ return lines
419
+
420
+ if ntype == "leaderboard_trader_fetch":
421
+ tid = g.param(node_id, "trader_id")
422
+ lines.append(
423
+ f"{_var(node_id, 'trader')} = "
424
+ f"sources.leaderboard_trader(src.leaderboard_trader(trader_id={_pyliteral(tid)}))"
425
+ )
426
+ return lines
427
+
428
+ if ntype == "open_positions_realized_pnl_fetch":
429
+ records_expr = _resolve_input(
430
+ g, node_id, next(p for p in nd.inputs if p.name == "positions"))
431
+ kw = _kwargs(g, node_id, ["latest"])
432
+ lines.append(
433
+ f"{_var(node_id, 'realized')} = sources.open_positions_realized_pnl("
434
+ f"src.open_positions_realized_pnl(records={records_expr}, {kw}))"
435
+ )
436
+ return lines
437
+
438
+ if ntype == "account_history_fetch":
439
+ kw = _kwargs(g, node_id, ["bucket_duration_ms"])
440
+ lines.append(
441
+ f"{_var(node_id, 'periods')} = sources.account_history(src.account_history({kw}))"
442
+ )
443
+ return lines
444
+
445
+ if ntype == "mcp_read":
446
+ # the escape hatch: a generic passthrough read. tool + verbatim args (snake_case) ->
447
+ # the adapter unwraps the standard envelope and returns the raw `data` payload, which a
448
+ # pure_fn normalizer downstream turns back into a typed shape.
449
+ tool = g.param(node_id, "tool")
450
+ args = g.param(node_id, "args") or {}
451
+ timeout = g.param(node_id, "timeout_seconds")
452
+ tail = ""
453
+ if isinstance(timeout, (int, float)) and not isinstance(timeout, bool) and timeout > 0:
454
+ tail = f", timeout_seconds={_pyliteral(timeout)}"
455
+ lines.append(
456
+ f"{_var(node_id, 'payload')} = src.raw_read("
457
+ f"{_pyliteral(tool)}, {_pyliteral(args)}{tail})"
458
+ )
459
+ return lines
460
+
461
+ if ntype == "threshold_gate":
462
+ # feature_map -> feature_map (not candles): its `values` input is wired from another
463
+ # feature node's output (answers indicators[].source), so it can't use the generic
464
+ # candles-fed indicator path below.
465
+ values_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "values"))
466
+ kw = _kwargs(g, node_id, ["op", "value"])
467
+ lines.append(f"{_var(node_id, 'passed')} = indicators.threshold_gate({values_expr}, {kw})")
468
+ return lines
469
+
470
+ if ntype == "aggregate_bias":
471
+ # record_list -> feature_map (not candles): its `traders` input is a cohort's TraderState
472
+ # records (from trader_states_fetch), so it can't use the generic candles-fed path below.
473
+ traders_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "traders"))
474
+ lines.append(f"{_var(node_id, 'bias')} = indicators.aggregate_bias({traders_expr})")
475
+ return lines
476
+
477
+ if nd.kind == NodeKind.indicator:
478
+ candles_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candles"))
479
+ outs = ", ".join(_var(node_id, p.name) for p in nd.outputs)
480
+ kw = _kwargs(g, node_id, [p.name for p in nd.params])
481
+ lines.append(f"{outs} = indicators.{ntype}({candles_expr}, {kw})")
482
+ return lines
483
+
484
+ if ntype == "held_asset_exclusion":
485
+ cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
486
+ held = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "held"))
487
+ lines.append(
488
+ f"{_var(node_id, 'kept')} = filters.held_asset_exclusion("
489
+ f"candidates={cand}, held={held})"
490
+ )
491
+ return lines
492
+
493
+ if ntype == "recent_signal_ttl_dedup":
494
+ cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
495
+ recent = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "recent"))
496
+ lines.append(
497
+ f"{_var(node_id, 'kept')} = filters.recent_signal_ttl_dedup("
498
+ f"candidates={cand}, recent={recent})"
499
+ )
500
+ return lines
501
+
502
+ if ntype == "classifier_predicate":
503
+ cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
504
+ kw = _kwargs(g, node_id, ["max_funding_abs", "max_leverage", "exclude_delisted"])
505
+ lines.append(
506
+ f"{_var(node_id, 'kept')} = filters.classifier_predicate(candidates={cand}, {kw})"
507
+ )
508
+ return lines
509
+
510
+ if ntype == "live_instrument_validation":
511
+ cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
512
+ insts = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "instruments"))
513
+ lines.append(
514
+ f"{_var(node_id, 'kept')} = filters.live_instrument_validation("
515
+ f"candidates={cand}, instruments={insts})"
516
+ )
517
+ return lines
518
+
519
+ if ntype == "hard_gates_all":
520
+ feats = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "features"))
521
+ merged = f"_features_{_sanitize(node_id)}"
522
+ kw = _kwargs(g, node_id, ["required_gates", "weights", "min_score"])
523
+ lines.append(f"{merged} = scorers.merge_features({feats})")
524
+ lines.append(f"{_var(node_id, 'scores')} = scorers.hard_gates_all({merged}, {kw})")
525
+ return lines
526
+
527
+ if ntype == "emit_single_best":
528
+ scores = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "scores"))
529
+ kw = _kwargs(g, node_id, ["min_score"])
530
+ lines.append(f"{_var(node_id, 'selected')} = rankers.emit_single_best({scores}, {kw})")
531
+ return lines
532
+
533
+ if ntype == "rank_pool_cap_to_open_slots":
534
+ scores = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "scores"))
535
+ held = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "held"))
536
+ kw = _kwargs(g, node_id, ["max_slots", "min_score"])
537
+ lines.append(
538
+ f"{_var(node_id, 'selected')} = rankers.rank_pool_cap_to_open_slots("
539
+ f"{scores}, held={held}, {kw})"
540
+ )
541
+ return lines
542
+
543
+ if ntype == "signal_envelope":
544
+ selected = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "selected"))
545
+ equity = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "equity"))
546
+ kw = _kwargs(g, node_id, ["margin_pct", "leverage", "clamp_leverage_to_venue"])
547
+ # Optional parity kwargs are appended ONLY when used, so graphs that set none of them
548
+ # emit byte-identically to the pre-batch-D compiler (existing goldens unchanged):
549
+ # - instruments (live board) is emitted only when a graph wires it (the real clamp);
550
+ # - valid_for_seconds only when a positive TTL override is set;
551
+ # - signal_type only when a non-empty label is set.
552
+ extra: list[str] = []
553
+ if g.incoming.get((node_id, "instruments")):
554
+ insts = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "instruments"))
555
+ extra.append(f"instruments={insts}")
556
+ vfs = g.param(node_id, "valid_for_seconds")
557
+ if isinstance(vfs, int) and not isinstance(vfs, bool) and vfs > 0:
558
+ extra.append(f"valid_for_seconds={_pyliteral(vfs)}")
559
+ st = g.param(node_id, "signal_type")
560
+ if isinstance(st, str) and st:
561
+ extra.append(f"signal_type={_pyliteral(st)}")
562
+ tail = (", " + ", ".join(extra)) if extra else ""
563
+ lines.append(
564
+ f"{_var(node_id, 'signals')} = emitters.signal_envelope({selected}, "
565
+ f"equity={equity}, {kw}{tail})"
566
+ )
567
+ return lines
568
+
569
+ raise CompileError(
570
+ f"node {node_id!r} has type {ntype!r} which the compiler cannot emit. Every registry "
571
+ f"primitive is emittable; bespoke scoring/classification is not a primitive — express "
572
+ f"that edge as a pure_fn node, as bison/magpie/thesis-fund do."
573
+ )
574
+
575
+
576
+ def _emit_stateful_prologue(g: _Graph, node_id: str) -> list[str]:
577
+ """READ half of a stateful node (runs BEFORE the body): load prior-tick state and expose the
578
+ node's prior-tick output ports + a working-state var carried to the epilogue. Dispatched by
579
+ node name; each stateful node appends its own self-contained single-key fragment to the shared
580
+ ctx.state series, so multiple stateful nodes coexist (stateful.py `_read_key` scans back)."""
581
+ nd = g.nodedef(node_id)
582
+ read = [f"# node: {node_id} ({nd.name}) [read] — {nd.summary}"]
583
+
584
+ if nd.name == "ttl_dedup_state":
585
+ kw = _kwargs(g, node_id, ["ttl_seconds", "prune_factor"])
586
+ statemap = f"_statemap_{_sanitize(node_id)}"
587
+ read.append(
588
+ f"{_var(node_id, 'recent')}, {statemap} = stateful.ttl_dedup_state_read("
589
+ f"ctx.state, {kw}, now=_now)")
590
+ return read
591
+
592
+ if nd.name == "snapshot_diff_state":
593
+ kw = _kwargs(g, node_id, ["value_field", "bucket_seconds", "max_snapshots"])
594
+ working = f"_snapwindow_{_sanitize(node_id)}"
595
+ read.append(
596
+ f"{_var(node_id, 'growth')}, {_var(node_id, 'deltas')}, {working} = "
597
+ f"stateful.snapshot_diff_state_read(ctx.state, {kw}, now=_now)")
598
+ return read
599
+
600
+ if nd.name == "cohort_cache_state":
601
+ kw = _kwargs(g, node_id, ["ttl_hours", "version"])
602
+ working = f"_cohortcache_{_sanitize(node_id)}"
603
+ read.append(
604
+ f"{_var(node_id, 'cohort')}, {working} = "
605
+ f"stateful.cohort_cache_state_read(ctx.state, {kw}, now=_now)")
606
+ return read
607
+
608
+ raise CompileError(f"stateful node {node_id!r} has unknown type {nd.name!r} (no prologue emitter)")
609
+
610
+
611
+ def _emit_stateful_epilogue(g: _Graph, node_id: str) -> list[str]:
612
+ """WRITE half of a stateful node (runs AFTER the body): fold THIS tick's late input into the
613
+ working state and persist a self-contained fragment. Dispatched by node name."""
614
+ nd = g.nodedef(node_id)
615
+
616
+ if nd.name == "ttl_dedup_state":
617
+ emitted = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "emitted"))
618
+ statemap = f"_statemap_{_sanitize(node_id)}"
619
+ return [
620
+ f"# node: {node_id} ({nd.name}) [write] — record this tick's signals in ctx.state",
621
+ f"stateful.ttl_dedup_state_write(ctx.state, {statemap}, emitted={emitted}, now=_now)",
622
+ ]
623
+
624
+ if nd.name == "snapshot_diff_state":
625
+ snapshot = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "snapshot"))
626
+ kw = _kwargs(g, node_id, ["value_field", "bucket_seconds", "max_snapshots"])
627
+ working = f"_snapwindow_{_sanitize(node_id)}"
628
+ return [
629
+ f"# node: {node_id} ({nd.name}) [write] — fold this tick's snapshot into ctx.state",
630
+ f"stateful.snapshot_diff_state_write(ctx.state, {working}, snapshot={snapshot}, "
631
+ f"{kw}, now=_now)",
632
+ ]
633
+
634
+ if nd.name == "cohort_cache_state":
635
+ fresh = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "fresh"))
636
+ kw = _kwargs(g, node_id, ["ttl_hours", "version"])
637
+ working = f"_cohortcache_{_sanitize(node_id)}"
638
+ return [
639
+ f"# node: {node_id} ({nd.name}) [write] — refresh-or-pin the cohort in ctx.state",
640
+ f"stateful.cohort_cache_state_write(ctx.state, {working}, fresh={fresh}, "
641
+ f"{kw}, now=_now)",
642
+ ]
643
+
644
+ raise CompileError(f"stateful node {node_id!r} has unknown type {nd.name!r} (no epilogue emitter)")
645
+
646
+
647
+ # --------------------------------------------------------------------------- driver
648
+
649
+ def _pure_fn_imports(g: _Graph) -> list[str]:
650
+ """The sorted set of allow-listed modules (math/statistics) any pure_fn body uses."""
651
+ allowed = {"math", "statistics"}
652
+ used: set[str] = set()
653
+ for pfn in g.pure_fns.values():
654
+ fd = pfn.function_def()
655
+ if fd is None:
656
+ continue
657
+ for node in ast.walk(fd):
658
+ if isinstance(node, ast.Name) and node.id in allowed:
659
+ used.add(node.id)
660
+ return sorted(used)
661
+
662
+
663
+ def _return_var(g: _Graph) -> str:
664
+ emitters_ids = [nid for nid in g.id_to_type if g.nodedef(nid).kind == NodeKind.emitter]
665
+ if len(emitters_ids) != 1:
666
+ raise CompileError(
667
+ f"a graph must have exactly one emitter (the scan() return); got {emitters_ids}"
668
+ )
669
+ return _var(emitters_ids[0], "signals")
670
+
671
+
672
+ def compile_text(text: str, source_name: str = "<graph>") -> str:
673
+ """Validate then compile graph YAML text into scan.py source (a str)."""
674
+ result = validate_text(text, source=source_name)
675
+ if not result.ok:
676
+ raise CompileError(
677
+ f"refusing to compile an invalid graph ({source_name}):\n{result.render()}"
678
+ )
679
+
680
+ data, _line_index = load_graph(text)
681
+ if has_pipeline(data):
682
+ data, _errs = desugar(data)
683
+ g = _Graph(data)
684
+
685
+ stateful_ids = sorted(
686
+ nid for nid in g.id_to_type if g.nodedef(nid).kind == NodeKind.stateful
687
+ )
688
+
689
+ prologue: list[str] = []
690
+ for sid in stateful_ids:
691
+ prologue.extend(_emit_stateful_prologue(g, sid))
692
+
693
+ body: list[str] = []
694
+ for nid in _topo_order(g):
695
+ block = _emit_body_node(g, nid)
696
+ if body:
697
+ body.append("")
698
+ body.extend(block)
699
+
700
+ epilogue: list[str] = []
701
+ for sid in stateful_ids:
702
+ if epilogue:
703
+ epilogue.append("")
704
+ epilogue.extend(_emit_stateful_epilogue(g, sid))
705
+
706
+ # Allow-listed modules any pure_fn body references (math / statistics) become
707
+ # module-level imports so the nested pure_fn defs can reach them. Sorted for
708
+ # determinism; empty (and thus absent) for pure-primitive graphs like stag/hawk.
709
+ extra_imports = _pure_fn_imports(g)
710
+
711
+ env = Environment(
712
+ loader=FileSystemLoader(str(_TEMPLATE_DIR)),
713
+ trim_blocks=True,
714
+ lstrip_blocks=True,
715
+ keep_trailing_newline=True,
716
+ )
717
+ template = env.get_template("scan.py.j2")
718
+ node_ids = ", ".join(n["id"] for n in g.nodes)
719
+ rendered = template.render(
720
+ source_name=source_name,
721
+ strategy=g.metadata.get("strategy", "unnamed"),
722
+ archetype=g.metadata.get("archetype", "unknown"),
723
+ node_count=len(g.nodes),
724
+ node_ids=node_ids,
725
+ base_universe=_pyliteral(g.base_universe()),
726
+ extra_imports=extra_imports,
727
+ prologue=prologue,
728
+ body=body,
729
+ epilogue=epilogue,
730
+ return_var=_return_var(g),
731
+ )
732
+ # normalize any trailing whitespace introduced by the template loop, keep 1 newline.
733
+ lines = [ln.rstrip() for ln in rendered.split("\n")]
734
+ return "\n".join(lines).rstrip("\n") + "\n"
735
+
736
+
737
+ def compile_file(path, source_name: str | None = None) -> str:
738
+ p = Path(path)
739
+ return compile_text(p.read_text(encoding="utf-8"), source_name or p.name)