@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,550 @@
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
+ if nd.kind == NodeKind.indicator:
339
+ candles_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candles"))
340
+ outs = ", ".join(_var(node_id, p.name) for p in nd.outputs)
341
+ kw = _kwargs(g, node_id, [p.name for p in nd.params])
342
+ lines.append(f"{outs} = indicators.{ntype}({candles_expr}, {kw})")
343
+ return lines
344
+
345
+ if ntype == "held_asset_exclusion":
346
+ cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
347
+ held = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "held"))
348
+ lines.append(
349
+ f"{_var(node_id, 'kept')} = filters.held_asset_exclusion("
350
+ f"candidates={cand}, held={held})"
351
+ )
352
+ return lines
353
+
354
+ if ntype == "recent_signal_ttl_dedup":
355
+ cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
356
+ recent = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "recent"))
357
+ lines.append(
358
+ f"{_var(node_id, 'kept')} = filters.recent_signal_ttl_dedup("
359
+ f"candidates={cand}, recent={recent})"
360
+ )
361
+ return lines
362
+
363
+ if ntype == "classifier_predicate":
364
+ cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
365
+ kw = _kwargs(g, node_id, ["max_funding_abs", "max_leverage", "exclude_delisted"])
366
+ lines.append(
367
+ f"{_var(node_id, 'kept')} = filters.classifier_predicate(candidates={cand}, {kw})"
368
+ )
369
+ return lines
370
+
371
+ if ntype == "live_instrument_validation":
372
+ cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
373
+ insts = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "instruments"))
374
+ lines.append(
375
+ f"{_var(node_id, 'kept')} = filters.live_instrument_validation("
376
+ f"candidates={cand}, instruments={insts})"
377
+ )
378
+ return lines
379
+
380
+ if ntype == "hard_gates_all":
381
+ feats = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "features"))
382
+ merged = f"_features_{_sanitize(node_id)}"
383
+ kw = _kwargs(g, node_id, ["required_gates", "weights", "min_score"])
384
+ lines.append(f"{merged} = scorers.merge_features({feats})")
385
+ lines.append(f"{_var(node_id, 'scores')} = scorers.hard_gates_all({merged}, {kw})")
386
+ return lines
387
+
388
+ if ntype == "emit_single_best":
389
+ scores = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "scores"))
390
+ kw = _kwargs(g, node_id, ["min_score"])
391
+ lines.append(f"{_var(node_id, 'selected')} = rankers.emit_single_best({scores}, {kw})")
392
+ return lines
393
+
394
+ if ntype == "rank_pool_cap_to_open_slots":
395
+ scores = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "scores"))
396
+ held = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "held"))
397
+ kw = _kwargs(g, node_id, ["max_slots", "min_score"])
398
+ lines.append(
399
+ f"{_var(node_id, 'selected')} = rankers.rank_pool_cap_to_open_slots("
400
+ f"{scores}, held={held}, {kw})"
401
+ )
402
+ return lines
403
+
404
+ if ntype == "signal_envelope":
405
+ selected = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "selected"))
406
+ equity = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "equity"))
407
+ kw = _kwargs(g, node_id, ["margin_pct", "leverage", "clamp_leverage_to_venue"])
408
+ # Optional parity kwargs are appended ONLY when used, so graphs that set none of them
409
+ # emit byte-identically to the pre-batch-D compiler (existing goldens unchanged):
410
+ # - instruments (live board) is emitted only when a graph wires it (the real clamp);
411
+ # - valid_for_seconds only when a positive TTL override is set;
412
+ # - signal_type only when a non-empty label is set.
413
+ extra: list[str] = []
414
+ if g.incoming.get((node_id, "instruments")):
415
+ insts = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "instruments"))
416
+ extra.append(f"instruments={insts}")
417
+ vfs = g.param(node_id, "valid_for_seconds")
418
+ if isinstance(vfs, int) and not isinstance(vfs, bool) and vfs > 0:
419
+ extra.append(f"valid_for_seconds={_pyliteral(vfs)}")
420
+ st = g.param(node_id, "signal_type")
421
+ if isinstance(st, str) and st:
422
+ extra.append(f"signal_type={_pyliteral(st)}")
423
+ tail = (", " + ", ".join(extra)) if extra else ""
424
+ lines.append(
425
+ f"{_var(node_id, 'signals')} = emitters.signal_envelope({selected}, "
426
+ f"equity={equity}, {kw}{tail})"
427
+ )
428
+ return lines
429
+
430
+ raise CompileError(
431
+ f"node {node_id!r} has type {ntype!r} which the compiler cannot emit. Every registry "
432
+ f"primitive is emittable; bespoke scoring/classification is not a primitive — express "
433
+ f"that edge as a pure_fn node, as bison/magpie/thesis-fund do."
434
+ )
435
+
436
+
437
+ def _emit_stateful_prologue(g: _Graph, node_id: str) -> list[str]:
438
+ nd = g.nodedef(node_id)
439
+ kw = _kwargs(g, node_id, ["ttl_seconds", "prune_factor"])
440
+ statemap = f"_statemap_{_sanitize(node_id)}"
441
+ return [
442
+ f"# node: {node_id} ({nd.name}) [read] — {nd.summary}",
443
+ f"{_var(node_id, 'recent')}, {statemap} = stateful.ttl_dedup_state_read("
444
+ f"ctx.state, {kw}, now=_now)",
445
+ ]
446
+
447
+
448
+ def _emit_stateful_epilogue(g: _Graph, node_id: str) -> list[str]:
449
+ nd = g.nodedef(node_id)
450
+ emitted = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "emitted"))
451
+ statemap = f"_statemap_{_sanitize(node_id)}"
452
+ return [
453
+ f"# node: {node_id} ({nd.name}) [write] — record this tick's signals in ctx.state",
454
+ f"stateful.ttl_dedup_state_write(ctx.state, {statemap}, emitted={emitted}, now=_now)",
455
+ ]
456
+
457
+
458
+ # --------------------------------------------------------------------------- driver
459
+
460
+ def _pure_fn_imports(g: _Graph) -> list[str]:
461
+ """The sorted set of allow-listed modules (math/statistics) any pure_fn body uses."""
462
+ allowed = {"math", "statistics"}
463
+ used: set[str] = set()
464
+ for pfn in g.pure_fns.values():
465
+ fd = pfn.function_def()
466
+ if fd is None:
467
+ continue
468
+ for node in ast.walk(fd):
469
+ if isinstance(node, ast.Name) and node.id in allowed:
470
+ used.add(node.id)
471
+ return sorted(used)
472
+
473
+
474
+ def _return_var(g: _Graph) -> str:
475
+ emitters_ids = [nid for nid in g.id_to_type if g.nodedef(nid).kind == NodeKind.emitter]
476
+ if len(emitters_ids) != 1:
477
+ raise CompileError(
478
+ f"a graph must have exactly one emitter (the scan() return); got {emitters_ids}"
479
+ )
480
+ return _var(emitters_ids[0], "signals")
481
+
482
+
483
+ def compile_text(text: str, source_name: str = "<graph>") -> str:
484
+ """Validate then compile graph YAML text into scan.py source (a str)."""
485
+ result = validate_text(text, source=source_name)
486
+ if not result.ok:
487
+ raise CompileError(
488
+ f"refusing to compile an invalid graph ({source_name}):\n{result.render()}"
489
+ )
490
+
491
+ data, _line_index = load_graph(text)
492
+ if has_pipeline(data):
493
+ data, _errs = desugar(data)
494
+ g = _Graph(data)
495
+
496
+ stateful_ids = sorted(
497
+ nid for nid in g.id_to_type if g.nodedef(nid).kind == NodeKind.stateful
498
+ )
499
+
500
+ prologue: list[str] = []
501
+ for sid in stateful_ids:
502
+ prologue.extend(_emit_stateful_prologue(g, sid))
503
+
504
+ body: list[str] = []
505
+ for nid in _topo_order(g):
506
+ block = _emit_body_node(g, nid)
507
+ if body:
508
+ body.append("")
509
+ body.extend(block)
510
+
511
+ epilogue: list[str] = []
512
+ for sid in stateful_ids:
513
+ if epilogue:
514
+ epilogue.append("")
515
+ epilogue.extend(_emit_stateful_epilogue(g, sid))
516
+
517
+ # Allow-listed modules any pure_fn body references (math / statistics) become
518
+ # module-level imports so the nested pure_fn defs can reach them. Sorted for
519
+ # determinism; empty (and thus absent) for pure-primitive graphs like stag/hawk.
520
+ extra_imports = _pure_fn_imports(g)
521
+
522
+ env = Environment(
523
+ loader=FileSystemLoader(str(_TEMPLATE_DIR)),
524
+ trim_blocks=True,
525
+ lstrip_blocks=True,
526
+ keep_trailing_newline=True,
527
+ )
528
+ template = env.get_template("scan.py.j2")
529
+ node_ids = ", ".join(n["id"] for n in g.nodes)
530
+ rendered = template.render(
531
+ source_name=source_name,
532
+ strategy=g.metadata.get("strategy", "unnamed"),
533
+ archetype=g.metadata.get("archetype", "unknown"),
534
+ node_count=len(g.nodes),
535
+ node_ids=node_ids,
536
+ base_universe=_pyliteral(g.base_universe()),
537
+ extra_imports=extra_imports,
538
+ prologue=prologue,
539
+ body=body,
540
+ epilogue=epilogue,
541
+ return_var=_return_var(g),
542
+ )
543
+ # normalize any trailing whitespace introduced by the template loop, keep 1 newline.
544
+ lines = [ln.rstrip() for ln in rendered.split("\n")]
545
+ return "\n".join(lines).rstrip("\n") + "\n"
546
+
547
+
548
+ def compile_file(path, source_name: str | None = None) -> str:
549
+ p = Path(path)
550
+ return compile_text(p.read_text(encoding="utf-8"), source_name or p.name)
@@ -0,0 +1,121 @@
1
+ # breakout_swing — breakout swing-trading strategy (tier 1) over a 3-asset basket, as a
2
+ # typed dataflow DAG.
3
+ #
4
+ # Hand-authored from the corpus-recon inventory (hawk section): a BTC/ETH/SOL basket
5
+ # scanned on 1h + 4h candles. Two HARD gates in the corpus (a directional breakout of the
6
+ # prior-7d closes, and smart-money agreement); the 4h trend-structure and volume ratio are
7
+ # score contributors. Single-best emission, universal TTL signal-dedup on ctx.state.
8
+ # Re-expression of corpus package hawk; semantics cited to skills/strategies/hawk/...
9
+ #
10
+ # Re-expression notes (correctness = the slice-3 goldens, not the original's bit output):
11
+ # - breakout_detect.direction is the sole hard gate (required_gates: [brk]); it is also
12
+ # the emitted trade direction (the scorer's direction detector reads the LONG/SHORT
13
+ # feature). Smart-money is a weighted contributor here rather than a second hard,
14
+ # direction-matched gate — the generic scorer gates on truthiness, not cross-feature
15
+ # agreement (documented deviation; corpus fidelity is the slice-8 oracle).
16
+ # - trend_structure emits BULLISH/BEARISH/NEUTRAL, so it can be wired into the same scorer
17
+ # as breakout_detect (both would otherwise be a "direction" port) without colliding.
18
+ # - feature/gate/weight keys are SOURCE NODE IDS (the compiler merges keyed by node id).
19
+
20
+ version: 1
21
+
22
+ metadata:
23
+ archetype: breakout
24
+ strategy: breakout_swing
25
+ corpus_origin: hawk # provenance only, for fidelity replay — not user-facing
26
+ exit_preset: protected_tight # slice 9: emitted as runtime.yaml exit: (hawk = cut failed breakouts fast)
27
+ # EDGE (verbatim, machine never interprets): "buy the liquid majors the moment price
28
+ # breaks a week of range with smart money leaning the same way; ride it with the DSL."
29
+
30
+ nodes:
31
+ # --- sources ---
32
+ - id: account
33
+ node: clearinghouse_state # dual-DEX max() equity + held-position set
34
+
35
+ - id: dedup_state
36
+ node: ttl_dedup_state # ctx.state TTL dedup; `recent` = assets within TTL
37
+ with:
38
+ ttl_seconds: 240
39
+
40
+ # --- universe narrowing (held-exclusion then recent-signal dedup) ---
41
+ - id: exclude_held
42
+ node: held_asset_exclusion # candidates default to the basket (candles whitelist)
43
+
44
+ - id: exclude_recent
45
+ node: recent_signal_ttl_dedup
46
+
47
+ # --- candle + leaderboard reads over the narrowed basket ---
48
+ - id: candles
49
+ node: candles_fetch
50
+ with:
51
+ whitelist: ["BTC", "ETH", "SOL"]
52
+ intervals: ["1h", "4h"]
53
+
54
+ - id: lean
55
+ node: sm_lean
56
+ with:
57
+ agree_threshold: 0.55 # breakout_swing's own 55% band (not conviction_scorer's 58/42)
58
+
59
+ # --- indicators ---
60
+ - id: brk
61
+ node: breakout_detect # HARD gate: prior-168h close high/low break
62
+ with:
63
+ window: 168
64
+ interval: "1h"
65
+
66
+ - id: trend4h
67
+ node: trend_structure # 4h higher-lows/lower-highs (score contributor)
68
+ with:
69
+ lookback: 6
70
+ interval: "4h"
71
+
72
+ - id: vol
73
+ node: volume_surge_ratio # 1h recent/baseline volume (score contributor)
74
+ with:
75
+ recent_window: 1
76
+ baseline_window: 10
77
+ interval: "1h"
78
+
79
+ # --- scorer: breakout is the only hard gate; trend/vol/SM contribute ---
80
+ - id: gates
81
+ node: hard_gates_all
82
+ with:
83
+ required_gates: ["brk"] # a decisive breakout (LONG/SHORT, not NEUTRAL)
84
+ # TODO(edge): tune these weights — the strategy's real edge lives here.
85
+ weights: { brk: 2.0, trend4h: 2.0, vol: 1.0, lean: 0.05 }
86
+ min_score: 5
87
+
88
+ # --- ranker + emitter ---
89
+ - id: best
90
+ node: emit_single_best
91
+ with:
92
+ min_score: 5
93
+
94
+ - id: emit
95
+ node: signal_envelope
96
+ with:
97
+ margin_pct: 20 # PERCENT of withdrawable (breakout_swing's flat 20%)
98
+ leverage: 5
99
+
100
+ edges:
101
+ # universe narrowing chain
102
+ - { from: account.held, to: exclude_held.held }
103
+ - { from: exclude_held.kept, to: exclude_recent.candidates }
104
+ - { from: dedup_state.recent, to: exclude_recent.recent }
105
+ - { from: exclude_recent.kept, to: candles.assets }
106
+ - { from: exclude_recent.kept, to: lean.assets }
107
+ # candles fan out to indicators
108
+ - { from: candles.candles, to: brk.candles }
109
+ - { from: candles.candles, to: trend4h.candles }
110
+ - { from: candles.candles, to: vol.candles }
111
+ # per-asset features MERGE into the scorer (keyed by source node id)
112
+ - { from: brk.direction, to: gates.features }
113
+ - { from: trend4h.direction, to: gates.features }
114
+ - { from: vol.ratio, to: gates.features }
115
+ - { from: lean.lean, to: gates.features }
116
+ # score -> rank -> emit
117
+ - { from: gates.scores, to: best.scores }
118
+ - { from: best.selected, to: emit.selected }
119
+ - { from: account.equity, to: emit.equity }
120
+ # close the dedup loop: this tick's signals are recorded in ctx.state
121
+ - { from: emit.signals, to: dedup_state.emitted }