@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,410 @@
1
+ """_common — shared archetype pieces (rankers, the reusable pure_fn bodies + test-gen).
2
+
3
+ Two concerns live here so the three archetype templates stay small and consistent:
4
+
5
+ * cardinality -> ranker: max_positions == 1 emits the single-best ranker; >1 emits the
6
+ rank-pool-cap-to-open-slots ranker (thesis-fund's shape) wired to the held set. This is
7
+ the mechanical binding of the "cardinality" answer (D7 step 3).
8
+ * bespoke scoring bodies WITH WORKING DEFAULTS: the classifier and thesis archetypes need
9
+ a cross-feature scorer no registry scorer expresses (the slice-3/4 vocabulary limit), so
10
+ they ship a pure_fn (D2 kind 2) pre-filled with a proven default body — magpie's
11
+ classifier predicate / thesis-fund's confirmation scorer. The body is the TODO slot the
12
+ model edits toward its edge; it is emitted verbatim AND executed here to synthesize
13
+ inline fixture tests that pass BY CONSTRUCTION, so the generated graph validates (the
14
+ pure_fn test stage runs) with zero edits regardless of the bound universe.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import difflib
20
+ from dataclasses import dataclass
21
+ from typing import Optional
22
+
23
+ from ..answers import Answers
24
+ from ..builder import BuiltNode, GraphBuilder
25
+
26
+
27
+ class GenerationError(ValueError):
28
+ """A teaching-shaped error raised when the ANSWERS reference something an archetype cannot
29
+ bind — an ``indicators`` entry naming a node the registry lacks (or a port it does not
30
+ declare), or a ``scoring`` name that matches no feature node wired into the scorer.
31
+
32
+ Surfaced like the builder's ``archetype template error:`` ValueErrors (a located,
33
+ fix-pointing message), but attributed to the answers rather than template drift: it is how
34
+ the F1 expressiveness surface refuses an impossible request LOUDLY, so a requested gate or
35
+ indicator is NEVER silently dropped from the emitted graph."""
36
+
37
+
38
+ # --------------------------------------------------------------------------- F1 (scoring + indicators)
39
+
40
+ @dataclass(frozen=True)
41
+ class ResolvedIndicator:
42
+ """One resolved ``answers.indicators`` entry, with its input-wiring decision (C1b).
43
+
44
+ ``in_port`` is the node's data input port (``candles`` for the candle-fed indicators,
45
+ ``values`` for ``threshold_gate``). When ``source_id`` is set the input is CHAINED off
46
+ that node's ``source_port`` output instead of the graph's candles; ``out_port`` is the
47
+ feature output merged into the scorer."""
48
+
49
+ id: str
50
+ out_port: str
51
+ in_port: Optional[str]
52
+ source_id: Optional[str] = None
53
+ source_port: Optional[str] = None
54
+
55
+
56
+ def _node_outputs(bn: BuiltNode) -> list[tuple[str, str]]:
57
+ """[(port_name, port_type_str)] for a BuiltNode's outputs (primitive or pure_fn)."""
58
+ from composer.registry import NODES_BY_NAME
59
+
60
+ if bn.kind == "pure_fn":
61
+ return [(p["name"], p["type"]) for p in bn.outputs]
62
+ return [(p.name, p.type.value) for p in NODES_BY_NAME[bn.node].outputs]
63
+
64
+
65
+ def _resolve_source_output(
66
+ b: GraphBuilder, source_id: str, resolved_by_id: dict, i: int, ind_id: str
67
+ ) -> tuple[str, str]:
68
+ """The (port_name, port_type) a ``source`` reference reads. Prefers an earlier
69
+ ``indicators`` entry's resolved feature port; else a single-output built-in node.
70
+ Teaches loudly when the source is unknown or ambiguous (never a silent drop)."""
71
+ bn = next((n for n in b.nodes if n.id == source_id), None)
72
+ if bn is None:
73
+ raise GenerationError(
74
+ f"indicators[{i}] (id {ind_id!r}) sets source {source_id!r}, which is not a node in "
75
+ f"this graph. `source` must name an EARLIER indicators entry, or a built-in feature "
76
+ f"node of the archetype."
77
+ )
78
+ outs = _node_outputs(bn)
79
+ if source_id in resolved_by_id:
80
+ want = resolved_by_id[source_id].out_port
81
+ typ = next((t for name, t in outs if name == want), None)
82
+ return want, typ
83
+ if len(outs) == 1:
84
+ return outs[0]
85
+ raise GenerationError(
86
+ f"indicators[{i}] (id {ind_id!r}) sets source {source_id!r}, which has "
87
+ f"{len(outs)} output ports {sorted(name for name, _ in outs)}; source chaining needs a "
88
+ f"single-output feature node (or reference an earlier indicators entry)."
89
+ )
90
+
91
+
92
+ def add_indicator_nodes(b: GraphBuilder, indicators) -> list[ResolvedIndicator]:
93
+ """Add each ``answers.indicators`` entry as an ADDITIONAL registry node (F1/C1b).
94
+
95
+ Returns a :class:`ResolvedIndicator` per entry for the caller to wire (input in, feature
96
+ out). Validates the node exists, its chosen output port is real, and — when ``source`` is
97
+ set — that the source exists and its output port type matches this node's input port type;
98
+ a :class:`GenerationError` otherwise (never a silent drop). Empty ``indicators`` is a no-op,
99
+ so an archetype threaded through this stays byte-identical to its pre-F1 output."""
100
+ from composer.registry import NODES_BY_NAME
101
+
102
+ resolved: list[ResolvedIndicator] = []
103
+ resolved_by_id: dict[str, ResolvedIndicator] = {}
104
+ for i, ind in enumerate(indicators):
105
+ nd = NODES_BY_NAME.get(ind.node)
106
+ if nd is None:
107
+ near = difflib.get_close_matches(ind.node, sorted(NODES_BY_NAME), n=3, cutoff=0.3)
108
+ hint = f" Did you mean: {', '.join(near)}?" if near else ""
109
+ raise GenerationError(
110
+ f"indicators[{i}] (id {ind.id!r}) requests node {ind.node!r}, which is not in "
111
+ f"the registry.{hint} Run `composer catalog` to see the available nodes."
112
+ )
113
+ out_ports = [p.name for p in nd.outputs]
114
+ port = ind.port or (out_ports[0] if out_ports else "")
115
+ if port not in out_ports:
116
+ raise GenerationError(
117
+ f"indicators[{i}] (id {ind.id!r}, node {ind.node!r}) names output port "
118
+ f"{ind.port!r}, which it does not have (has: {sorted(out_ports)})."
119
+ )
120
+ # the node's data input port (the single non-nothing input every feature node declares).
121
+ in_ports = list(nd.inputs)
122
+ in_port = in_ports[0].name if in_ports else None
123
+ in_port_type = in_ports[0].type.value if in_ports else None
124
+
125
+ source_id = source_port = None
126
+ if ind.source is not None:
127
+ if in_port is None:
128
+ raise GenerationError(
129
+ f"indicators[{i}] (id {ind.id!r}, node {ind.node!r}) sets a source but "
130
+ f"declares no input port to receive it."
131
+ )
132
+ src_port, src_type = _resolve_source_output(b, ind.source, resolved_by_id, i, ind.id)
133
+ if src_type != in_port_type:
134
+ raise GenerationError(
135
+ f"indicators[{i}] (id {ind.id!r}) sets source {ind.source!r} whose output "
136
+ f"{src_port!r} is type {src_type!r}, but {ind.node!r}'s input {in_port!r} is "
137
+ f"type {in_port_type!r} — a chained source must feed a port of the same type."
138
+ )
139
+ source_id, source_port = ind.source, src_port
140
+ elif in_port != "candles":
141
+ raise GenerationError(
142
+ f"indicators[{i}] (id {ind.id!r}, node {ind.node!r}) is not candle-fed (its input "
143
+ f"is {in_port!r}); it needs a `source:` naming the feature node whose output feeds "
144
+ f"it (e.g. a threshold_gate reading a recent_high_bars_ago output)."
145
+ )
146
+
147
+ header = ("additional indicators requested via answers.indicators.",) if i == 0 else ()
148
+ b.add(ind.id, ind.node, dict(ind.with_), header=header)
149
+ r = ResolvedIndicator(id=ind.id, out_port=port, in_port=in_port,
150
+ source_id=source_id, source_port=source_port)
151
+ resolved.append(r)
152
+ resolved_by_id[ind.id] = r
153
+ return resolved
154
+
155
+
156
+ def wire_indicator_inputs(b: GraphBuilder, resolved: list[ResolvedIndicator], *, candles_id: str) -> None:
157
+ """Wire every extra indicator's data input: the graph's candles by default, or a CHAINED
158
+ feature node's output when the entry set ``source`` (F1/C1b)."""
159
+ for j, r in enumerate(resolved):
160
+ if r.source_id is not None:
161
+ b.connect(r.source_id, r.source_port, r.id, r.in_port,
162
+ section="a chained indicator reads another node's feature" if j == 0 else None)
163
+ else:
164
+ b.connect(candles_id, "candles", r.id, r.in_port,
165
+ section="extra indicators read the same candles" if j == 0 else None)
166
+
167
+
168
+ def wire_indicator_features(b: GraphBuilder, resolved: list[ResolvedIndicator], *, scorer_id: str) -> None:
169
+ """Merge every extra indicator's feature output into the scorer, keyed by its node id (F1)."""
170
+ for j, r in enumerate(resolved):
171
+ b.connect(r.id, r.out_port, scorer_id, "features",
172
+ section="extra indicator features merge into the scorer" if j == 0 else None)
173
+
174
+
175
+ def resolve_scoring(
176
+ answers: Answers,
177
+ *,
178
+ default_required,
179
+ default_weights: dict,
180
+ default_min,
181
+ feature_ids,
182
+ ) -> tuple[list[str], dict, object]:
183
+ """Resolve ``hard_gates_all`` params from the archetype default + optional answers.scoring.
184
+
185
+ "Answers override else current defaults" (D7): each ``scoring`` field left ``None`` keeps
186
+ the default; a set field replaces it. Every referenced name (``required_gates`` entries +
187
+ ``weights`` keys) MUST be a feature node id wired into the scorer, else a
188
+ :class:`GenerationError` names the unknown reference and the real feature ids — never a
189
+ silent drop. With no ``scoring`` the returned values are the defaults unchanged, so the
190
+ emitted graph is byte-identical to the pre-F1 output."""
191
+ req = list(default_required)
192
+ weights = dict(default_weights)
193
+ min_score = default_min
194
+ sc = answers.scoring
195
+ if sc is not None:
196
+ if sc.required_gates is not None:
197
+ req = list(sc.required_gates)
198
+ if sc.weights is not None:
199
+ weights = dict(sc.weights)
200
+ if sc.min_score is not None:
201
+ min_score = sc.min_score
202
+ valid = set(feature_ids)
203
+ unknown = [g for g in req if g not in valid]
204
+ unknown += [w for w in weights if w not in valid]
205
+ if unknown:
206
+ raise GenerationError(
207
+ f"scoring references {sorted(set(unknown))}, which are not feature nodes in this "
208
+ f"graph. The scorable features are {sorted(valid)} (SOURCE NODE IDS wired into the "
209
+ f"scorer). Add the missing feature via `indicators:`, or fix the name."
210
+ )
211
+ return req, weights, min_score
212
+
213
+
214
+ def add_ranker(
215
+ b: GraphBuilder,
216
+ *,
217
+ ranker_id: str,
218
+ scorer_id: str,
219
+ scorer_port: str,
220
+ account_id: str | None,
221
+ max_positions: int,
222
+ ) -> None:
223
+ """Add the ranker dictated by cardinality and wire the scorer into it.
224
+
225
+ Returns nothing; the caller wires ``ranker_id.selected`` onward to the emitter.
226
+ """
227
+ if max_positions <= 1:
228
+ b.add(
229
+ ranker_id,
230
+ "emit_single_best",
231
+ {"min_score": 0},
232
+ header=("ranker: single best candidate (cardinality = 1).",),
233
+ )
234
+ b.connect(scorer_id, scorer_port, ranker_id, "scores", section="score -> rank -> emit")
235
+ else:
236
+ b.add(
237
+ ranker_id,
238
+ "rank_pool_cap_to_open_slots",
239
+ {"max_slots": max_positions, "min_score": 0},
240
+ header=(
241
+ f"ranker: rank the pool, fill up to {max_positions} open slots "
242
+ f"(cardinality = {max_positions}).",
243
+ ),
244
+ )
245
+ b.connect(scorer_id, scorer_port, ranker_id, "scores", section="score -> rank -> emit")
246
+ if account_id is not None:
247
+ b.connect(account_id, "held", ranker_id, "held")
248
+
249
+
250
+ # --------------------------------------------------------------------------- pure_fn bodies
251
+
252
+ # magpie/pre_listing build_thesis_pre_listing (corpus), the classifier default body.
253
+ CLASSIFIER_FN = '''\
254
+ def classify_candidate(trend4h, trend1h, sm, min_score):
255
+ # TODO(edit): this is a WORKING DEFAULT classifier (magpie pre-listing shape). The 4h
256
+ # structure classifies the side (NEUTRAL -> not tradeable); 1h + smart money confirm;
257
+ # sparse SM falls back to trend-only. Encode YOUR edge by editing this body — it is
258
+ # purity-checked (no ctx/MCP/I/O) and its inline tests run at validate time.
259
+ scores = {}
260
+ assets = sorted(set(trend4h) | set(trend1h) | set(sm))
261
+ for asset in assets:
262
+ t4 = trend4h.get(asset, "NEUTRAL")
263
+ if t4 == "NEUTRAL":
264
+ continue
265
+ direction = "LONG" if t4 == "BULLISH" else "SHORT"
266
+ score = 3.0
267
+ reasons = ["4h_" + t4.lower()]
268
+ t1 = trend1h.get(asset, "NEUTRAL")
269
+ if (direction == "LONG" and t1 == "BULLISH") or (direction == "SHORT" and t1 == "BEARISH"):
270
+ score = score + 2.0
271
+ reasons.append("1h_confirms")
272
+ lean = sm.get(asset)
273
+ if lean is None:
274
+ score = score + 2.0
275
+ reasons.append("sm_sparse_assumed_aligned")
276
+ else:
277
+ tilt = lean if direction == "LONG" else 100.0 - lean
278
+ if tilt < 55.0:
279
+ continue
280
+ score = score + 2.0
281
+ reasons.append("sm_aligned")
282
+ if tilt >= 70.0:
283
+ score = score + 1.0
284
+ reasons.append("sm_strong")
285
+ if score < min_score:
286
+ continue
287
+ scores[asset] = {"score": round(score, 4), "direction": direction, "reasons": reasons}
288
+ return scores
289
+ '''
290
+
291
+ # thesis-fund score_thesis (corpus), the fixed-per-leg confirmation default body.
292
+ THESIS_FN = '''\
293
+ def score_thesis(trend4h, trend1h, rsi, own24h, long_basket, short_basket, min_score):
294
+ # TODO(edit): this is a WORKING DEFAULT confirmation scorer (thesis-fund shape). A name's
295
+ # direction is FIXED by its leg (long_basket -> LONG, short_basket -> SHORT), NOT derived;
296
+ # a name whose 4h structure OPPOSES its leg is dropped ("don't fight the tape"), else a
297
+ # weighted CONFIRMATION score. Encode YOUR edge by editing this body — it is purity-checked.
298
+ scores = {}
299
+ longs = {str(a).upper() for a in long_basket}
300
+ shorts = {str(a).upper() for a in short_basket}
301
+ assets = sorted(set(trend4h) | set(trend1h) | set(rsi) | set(own24h))
302
+ for asset in assets:
303
+ au = str(asset).upper()
304
+ if au in longs:
305
+ direction = "LONG"
306
+ elif au in shorts:
307
+ direction = "SHORT"
308
+ else:
309
+ continue
310
+ t4 = trend4h.get(asset, "NEUTRAL")
311
+ t1 = trend1h.get(asset, "NEUTRAL")
312
+ r = rsi.get(asset, 50.0)
313
+ own = own24h.get(asset, 0.0)
314
+ score = 0.0
315
+ reasons = []
316
+ if direction == "LONG":
317
+ if t4 == "BEARISH":
318
+ continue
319
+ if t4 == "BULLISH":
320
+ score = score + 3.0
321
+ reasons.append("4h_bullish")
322
+ else:
323
+ score = score + 1.0
324
+ reasons.append("4h_neutral")
325
+ if t1 == "BULLISH":
326
+ score = score + 1.0
327
+ reasons.append("1h_bullish")
328
+ elif t1 == "BEARISH":
329
+ score = score - 1.0
330
+ reasons.append("1h_bearish")
331
+ if own >= 1.0:
332
+ score = score + 2.0
333
+ reasons.append("mom_strong")
334
+ elif own >= 0.0:
335
+ score = score + 1.0
336
+ reasons.append("mom")
337
+ if r < 78.0:
338
+ score = score + 1.0
339
+ reasons.append("rsi_room")
340
+ else:
341
+ if t4 == "BULLISH":
342
+ continue
343
+ if t4 == "BEARISH":
344
+ score = score + 3.0
345
+ reasons.append("4h_bearish")
346
+ else:
347
+ score = score + 1.0
348
+ reasons.append("4h_neutral")
349
+ if t1 == "BEARISH":
350
+ score = score + 1.0
351
+ reasons.append("1h_bearish")
352
+ elif t1 == "BULLISH":
353
+ score = score - 1.0
354
+ reasons.append("1h_bullish")
355
+ if own <= -1.0:
356
+ score = score + 2.0
357
+ reasons.append("mom_strong")
358
+ elif own <= 0.0:
359
+ score = score + 1.0
360
+ reasons.append("mom")
361
+ if r > 22.0:
362
+ score = score + 1.0
363
+ reasons.append("rsi_room")
364
+ if score < min_score:
365
+ continue
366
+ scores[asset] = {"score": round(score, 4), "direction": direction, "reasons": reasons}
367
+ return scores
368
+ '''
369
+
370
+
371
+ def _exec_fn(fn_src: str):
372
+ ns: dict = {}
373
+ exec(fn_src, ns) # noqa: S102 - trusted, in-repo reference body used only for test-gen
374
+ for name, obj in ns.items():
375
+ if callable(obj) and name != "__builtins__":
376
+ return obj
377
+ raise RuntimeError("no function found in reference body")
378
+
379
+
380
+ def classifier_tests(min_score: float) -> tuple[dict, ...]:
381
+ """Two inline tests whose expected values are computed from the default body itself, so
382
+ they pass by construction (the emitted body == the executed body)."""
383
+ fn = _exec_fn(CLASSIFIER_FN)
384
+ confirmed_in = {"trend4h": {"ACME": "BULLISH"}, "trend1h": {"ACME": "BULLISH"}, "sm": {"ACME": 60.0}}
385
+ neutral_in = {"trend4h": {"ACME": "NEUTRAL"}, "trend1h": {"ACME": "BULLISH"}, "sm": {"ACME": 60.0}}
386
+ confirmed = fn(confirmed_in["trend4h"], confirmed_in["trend1h"], confirmed_in["sm"], min_score)
387
+ neutral = fn(neutral_in["trend4h"], neutral_in["trend1h"], neutral_in["sm"], min_score)
388
+ return (
389
+ {"name": "confirmed_side_scored", "inputs": confirmed_in, "expect": {"scores": confirmed}},
390
+ {"name": "neutral_4h_dropped", "inputs": neutral_in, "expect": {"scores": neutral}},
391
+ )
392
+
393
+
394
+ def thesis_tests(
395
+ long_basket: list[str], short_basket: list[str], min_score: float
396
+ ) -> tuple[dict, ...]:
397
+ """Two inline tests (one per leg) computed from the default body with the BOUND baskets,
398
+ so they pass whatever universe was chosen."""
399
+ fn = _exec_fn(THESIS_FN)
400
+ longs = list(long_basket) or ["BTC"]
401
+ shorts = list(short_basket) or ["ETH"]
402
+ la, sa = str(longs[0]).upper(), str(shorts[0]).upper()
403
+ long_in = {"trend4h": {la: "BULLISH"}, "trend1h": {la: "BULLISH"}, "rsi": {la: 50.0}, "own24h": {la: 10.0}}
404
+ short_in = {"trend4h": {sa: "BEARISH"}, "trend1h": {sa: "BEARISH"}, "rsi": {sa: 50.0}, "own24h": {sa: -10.0}}
405
+ lo = fn(long_in["trend4h"], long_in["trend1h"], long_in["rsi"], long_in["own24h"], longs, shorts, min_score)
406
+ so = fn(short_in["trend4h"], short_in["trend1h"], short_in["rsi"], short_in["own24h"], longs, shorts, min_score)
407
+ return (
408
+ {"name": "long_leg_confirmed", "inputs": long_in, "expect": {"scores": lo}},
409
+ {"name": "short_leg_confirmed", "inputs": short_in, "expect": {"scores": so}},
410
+ )
@@ -0,0 +1,134 @@
1
+ """breakout — the hawk shape (D7 step 2): a directional range-break as the hard gate, over a
2
+ FIXED basket or a DISCOVERED universe, with trend / volume / smart-money as weighted
3
+ contributors. Ported from the hand-authored ``examples/breakout_swing.graph.yaml`` (the spec)
4
+ and made an archetype so `composer new --archetype breakout` reproduces it — and, composed
5
+ with the F1 ``scoring`` / ``indicators`` surface, the 4-hard-gate discovery variant the first
6
+ real dev test agreed but generation could not produce.
7
+
8
+ Mechanically bound from the answers (D7 step 3): universe -> candles whitelist OR the live
9
+ instrument board (discover); intervals -> candles + indicators; ttl -> ttl_dedup_state;
10
+ cardinality -> ranker; risk -> emitter. The scorer default hard-gates ONLY the breakout
11
+ (``required_gates: ["brk"]``) with trend/vol/SM as weights — the example's shape. Answers
12
+ that pass ``scoring`` re-select the hard gates (e.g. all four of breakout / freshness /
13
+ volume / smart-money), and ``indicators`` add registry nodes the fixed set does not carry
14
+ (e.g. ``recent_high_bars_ago`` for the "don't-chase" freshness gate) — no hand edit.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from ..answers import Answers
20
+ from ..builder import GraphBuilder
21
+ from . import _common
22
+
23
+ BANNER = [
24
+ "breakout strategy — machine-generated anchor (composer new).",
25
+ "",
26
+ "A directional range-break (breakout_detect) is the hard gate; 4h trend structure, a",
27
+ "volume surge, and smart-money lean contribute to a weighted score, then single-best (or",
28
+ "pool-cap) emission with the universal TTL signal-dedup. The universe is a fixed basket or",
29
+ "discovered each tick from the live instrument board. Edit ONLY the TODO(edit) slots on the",
30
+ "scorer to move this toward your edge; every intermediate state stays valid (`composer check`).",
31
+ ]
32
+
33
+
34
+ def build(answers: Answers) -> GraphBuilder:
35
+ b = GraphBuilder()
36
+ iv = answers.intervals[0] if answers.intervals else "1h"
37
+ u = answers.universe
38
+
39
+ b.add("account", "clearinghouse_state", header=("sources.",))
40
+ b.add("dedup_state", "ttl_dedup_state", {"ttl_seconds": answers.ttl_seconds})
41
+
42
+ if u.discover:
43
+ b.add("board", "instrument_list", {"dex": u.dex},
44
+ header=("dynamic universe discovery: live board -> leverage/volume/prefix signature.",))
45
+ b.add("universe", "universe_discover",
46
+ {"name_prefix": u.name_prefix, "max_leverage_cap": u.max_leverage_cap,
47
+ "min_day_volume": u.min_day_volume})
48
+ b.add("exclude_held", "held_asset_exclusion",
49
+ header=("universe narrowing over the discovered set.",))
50
+ else:
51
+ b.add("exclude_held", "held_asset_exclusion",
52
+ header=("universe narrowing over the fixed basket (candles supplies it).",))
53
+ b.add("exclude_recent", "recent_signal_ttl_dedup")
54
+
55
+ candles_params = {"intervals": list(answers.intervals)}
56
+ if not u.discover:
57
+ candles_params = {"whitelist": list(u.whitelist), "intervals": list(answers.intervals)}
58
+ b.add("candles", "candles_fetch", candles_params,
59
+ header=("candle + leaderboard reads over the narrowed universe.",))
60
+ b.add("lean", "sm_lean", {"agree_threshold": 0.55},
61
+ param_comments={"agree_threshold": "TODO(edit): smart-money agreement band (hawk 0.55)."},
62
+ slots=("agree_threshold",))
63
+
64
+ b.add("brk", "breakout_detect", {"window": 168, "interval": iv},
65
+ header=("indicators: candles -> per-asset features.",))
66
+ b.add("trend4h", "trend_structure", {"lookback": 6, "interval": "4h"})
67
+ b.add("vol", "volume_surge_ratio", {"recent_window": 1, "baseline_window": 10, "interval": iv})
68
+
69
+ # F1 — ADDITIONAL registry indicators requested by the answers (empty => no-op).
70
+ extra = _common.add_indicator_nodes(b, answers.indicators)
71
+
72
+ # F1 — the scorer's hard-vs-weighted split. DEFAULT = the example's shape: breakout is the
73
+ # sole hard gate, trend/vol/SM are weighted contributors. answers.scoring overrides it (e.g.
74
+ # the incident's four hard gates: brk + a freshness indicator + vol + lean).
75
+ feature_ids = ["brk", "trend4h", "vol", "lean"] + [r.id for r in extra]
76
+ required_gates, weights, min_score = _common.resolve_scoring(
77
+ answers,
78
+ default_required=("brk",),
79
+ default_weights={"brk": 2.0, "trend4h": 2.0, "vol": 1.0, "lean": 0.05},
80
+ default_min=5,
81
+ feature_ids=feature_ids,
82
+ )
83
+ b.add(
84
+ "gates", "hard_gates_all",
85
+ {
86
+ "required_gates": required_gates,
87
+ "weights": weights,
88
+ "min_score": min_score,
89
+ },
90
+ header=("scorer: hard gates + weighted sum — THE EDGE SLOT (see the EDGE block above).",),
91
+ param_comments={
92
+ "required_gates": "TODO(edit): feature keys (SOURCE NODE IDS) that must ALL pass.",
93
+ "weights": "TODO(edit): source-node-id -> weight; this weighted sum IS your edge.",
94
+ "min_score": "TODO(edit): minimum score to emit.",
95
+ },
96
+ slots=("required_gates", "weights", "min_score"),
97
+ )
98
+ b.set_edge_anchor("gates")
99
+
100
+ _common.add_ranker(
101
+ b, ranker_id="best", scorer_id="gates", scorer_port="scores",
102
+ account_id="account", max_positions=answers.max_positions,
103
+ )
104
+ b.add("emit", "signal_envelope",
105
+ {"margin_pct": answers.risk.margin_pct, "leverage": answers.risk.leverage},
106
+ header=("emitter: build the scan() signal list.",))
107
+
108
+ # universe wiring
109
+ if u.discover:
110
+ b.connect("board", "instruments", "universe", "instruments", section="dynamic universe discovery")
111
+ b.connect("universe", "universe", "exclude_held", "candidates", section="universe narrowing chain")
112
+ b.connect("account", "held", "exclude_held", "held",
113
+ section=None if u.discover else "universe narrowing chain")
114
+ b.connect("exclude_held", "kept", "exclude_recent", "candidates")
115
+ b.connect("dedup_state", "recent", "exclude_recent", "recent")
116
+ b.connect("exclude_recent", "kept", "candles", "assets")
117
+ b.connect("exclude_recent", "kept", "lean", "assets")
118
+ # candles fan out to indicators
119
+ b.connect("candles", "candles", "brk", "candles", section="candles fan out to indicators")
120
+ b.connect("candles", "candles", "trend4h", "candles")
121
+ b.connect("candles", "candles", "vol", "candles")
122
+ _common.wire_indicator_inputs(b, extra, candles_id="candles")
123
+ # per-asset features MERGE into the scorer (keyed by source node id)
124
+ b.connect("brk", "direction", "gates", "features", section="per-asset features merge into the scorer (keyed by source node id)")
125
+ b.connect("trend4h", "direction", "gates", "features")
126
+ b.connect("vol", "ratio", "gates", "features")
127
+ b.connect("lean", "lean", "gates", "features")
128
+ _common.wire_indicator_features(b, extra, scorer_id="gates")
129
+ # score -> rank -> emit (rank wiring added by add_ranker)
130
+ b.connect("best", "selected", "emit", "selected")
131
+ b.connect("account", "equity", "emit", "equity")
132
+ # close the dedup loop
133
+ b.connect("emit", "signals", "dedup_state", "emitted", section="close the dedup loop: this tick's signals are recorded in ctx.state")
134
+ return b
@@ -0,0 +1,109 @@
1
+ """classifier — the magpie shape (D7 step 2): a per-graph pure_fn CLASSIFIER predicate over
2
+ trend structure + smart money, on either a discovered or a fixed universe.
3
+
4
+ The direction classifier is cross-feature logic no registry scorer expresses (the slice-4
5
+ finding), so it is a pure_fn (D2 kind 2) carrying a WORKING DEFAULT body (magpie's
6
+ pre-listing predicate) as the TODO slot the model edits. Mechanically bound: discovery
7
+ params (dex / prefix / leverage cap / volume floor) -> the instrument_list + classifier_
8
+ predicate + universe_discover trio, or a fixed whitelist -> candles; intervals, ttl, risk,
9
+ cardinality bind as usual.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ..answers import Answers
15
+ from ..builder import GraphBuilder
16
+ from . import _common
17
+
18
+ BANNER = [
19
+ "classifier strategy — machine-generated anchor (composer new).",
20
+ "",
21
+ "Trend structure at 4h + 1h feeds a per-graph pure_fn CLASSIFIER that fixes the side and",
22
+ "confirms with smart money (magpie pre-listing shape). Universe is either discovered each",
23
+ "tick from the live instrument board (funding/leverage/volume signature) or a fixed",
24
+ "whitelist. Edit ONLY the pure_fn body (the EDGE slot) to encode your classification edge.",
25
+ ]
26
+
27
+ _MIN_SCORE = 3.0
28
+
29
+
30
+ def build(answers: Answers) -> GraphBuilder:
31
+ b = GraphBuilder()
32
+ u = answers.universe
33
+
34
+ b.add("account", "clearinghouse_state", header=("sources.",))
35
+ b.add("dedup_state", "ttl_dedup_state", {"ttl_seconds": answers.ttl_seconds})
36
+
37
+ if u.discover:
38
+ b.add("board", "instrument_list", {"dex": u.dex},
39
+ header=("dynamic universe discovery: live board -> funding signature -> prefix/leverage/volume.",))
40
+ b.add("ipop_funding", "classifier_predicate",
41
+ {"max_funding_abs": 0.0000001, "max_leverage": u.max_leverage_cap, "exclude_delisted": True})
42
+ b.add("ipop_universe", "universe_discover",
43
+ {"name_prefix": u.name_prefix, "max_leverage_cap": u.max_leverage_cap, "min_day_volume": u.min_day_volume})
44
+ b.add("exclude_held", "held_asset_exclusion",
45
+ header=("universe narrowing over the discovered set.",))
46
+ else:
47
+ b.add("exclude_held", "held_asset_exclusion",
48
+ header=("universe narrowing over the fixed whitelist (candles supplies it).",))
49
+ b.add("exclude_recent", "recent_signal_ttl_dedup")
50
+
51
+ candles_params = {"intervals": list(answers.intervals)}
52
+ if not u.discover:
53
+ candles_params = {"whitelist": list(u.whitelist), "intervals": list(answers.intervals)}
54
+ b.add("candles", "candles_fetch", candles_params,
55
+ header=("candle + leaderboard reads over the narrowed universe.",))
56
+ b.add("lean", "sm_lean", {"agree_threshold": 0.55},
57
+ param_comments={"agree_threshold": "TODO(edit): smart-money agreement band."},
58
+ slots=("agree_threshold",))
59
+
60
+ b.add("trend4h", "trend_structure", {"lookback": 6, "interval": "4h"},
61
+ header=("trend structure at both intervals (the classifier's inputs).",))
62
+ b.add("trend1h", "trend_structure", {"lookback": 6, "interval": "1h"})
63
+
64
+ b.add_pure_fn(
65
+ "classify",
66
+ inputs=(
67
+ {"name": "trend4h", "type": "feature_map", "doc": "4h trend structure (BULLISH/BEARISH/NEUTRAL)."},
68
+ {"name": "trend1h", "type": "feature_map", "doc": "1h trend structure (BULLISH/BEARISH/NEUTRAL)."},
69
+ {"name": "sm", "type": "feature_map", "doc": "Smart-money long lean percent [0,100]; may be absent."},
70
+ ),
71
+ outputs=({"name": "scores", "type": "score_map", "doc": "Per-asset {score, direction, reasons[]}."},),
72
+ params=({"name": "min_score", "type": "float", "default": _MIN_SCORE, "doc": "Minimum score to emit."},),
73
+ fn=_common.CLASSIFIER_FN,
74
+ tests=_common.classifier_tests(_MIN_SCORE),
75
+ header=("pure_fn classifier — THE EDGE SLOT (edit the body; see the EDGE block above).",),
76
+ )
77
+ b.set_edge_anchor("classify")
78
+
79
+ _common.add_ranker(
80
+ b, ranker_id="best", scorer_id="classify", scorer_port="scores",
81
+ account_id="account", max_positions=answers.max_positions,
82
+ )
83
+ b.add("emit", "signal_envelope",
84
+ {"margin_pct": answers.risk.margin_pct, "leverage": answers.risk.leverage},
85
+ header=("emitter.",))
86
+
87
+ # universe wiring
88
+ if u.discover:
89
+ b.connect("board", "instruments", "ipop_funding", "candidates", section="dynamic universe discovery")
90
+ b.connect("ipop_funding", "kept", "ipop_universe", "instruments")
91
+ b.connect("ipop_universe", "universe", "exclude_held", "candidates", section="universe narrowing chain")
92
+ b.connect("account", "held", "exclude_held", "held",
93
+ section=None if u.discover else "universe narrowing chain")
94
+ b.connect("exclude_held", "kept", "exclude_recent", "candidates")
95
+ b.connect("dedup_state", "recent", "exclude_recent", "recent")
96
+ b.connect("exclude_recent", "kept", "candles", "assets")
97
+ b.connect("exclude_recent", "kept", "lean", "assets")
98
+ # candles -> trend structure
99
+ b.connect("candles", "candles", "trend4h", "candles", section="candles fan out to trend structure")
100
+ b.connect("candles", "candles", "trend1h", "candles")
101
+ # features into the pure_fn (single writer per declared port)
102
+ b.connect("trend4h", "direction", "classify", "trend4h", section="features into the classifier pure_fn")
103
+ b.connect("trend1h", "direction", "classify", "trend1h")
104
+ b.connect("lean", "lean", "classify", "sm")
105
+ # classify -> rank -> emit
106
+ b.connect("best", "selected", "emit", "selected")
107
+ b.connect("account", "equity", "emit", "equity")
108
+ b.connect("emit", "signals", "dedup_state", "emitted", section="close the dedup loop")
109
+ return b