@senpi/runtime 3.0.9-dev.feat-strategy-compos.20260718011320 → 3.0.9-dev.feat-strategy-compos.20260719221425
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.
- package/dist/composer/authoring/__init__.py +2 -0
- package/dist/composer/authoring/answers.py +54 -1
- package/dist/composer/authoring/archetypes/classifier.py +1 -0
- package/dist/composer/authoring/archetypes/relative_value.py +1 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +1 -0
- package/dist/composer/authoring/builder.py +7 -1
- package/dist/composer/authoring/emit.py +8 -2
- package/dist/composer/authoring/generate.py +47 -1
- package/dist/composer/authoring/runtime_emit.py +69 -33
- package/dist/composer/generated/catalog.md +4 -24
- package/dist/composer/generated/describe.md +20 -0
- package/dist/composer/generated/graph.schema.json +4 -0
- package/dist/composer/pure_fn/models.py +8 -0
- package/dist/composer/registry/generate.py +190 -7
- package/dist/composer/validator/errors.py +1 -0
- package/dist/composer/verbs/_dsl_state.py +419 -0
- package/dist/composer/verbs/_unit_diff.py +446 -0
- package/dist/composer/verbs/check.py +74 -1
- package/dist/composer/verbs/deploy.py +1 -1
- package/dist/composer/verbs/docs.py +8 -3
- package/dist/composer/verbs/runtime_validate.py +62 -0
- package/dist/composer/verbs/update.py +153 -10
- package/package.json +1 -1
|
@@ -20,6 +20,7 @@ from .answers import (
|
|
|
20
20
|
Answers,
|
|
21
21
|
ExitSignals,
|
|
22
22
|
Indicator,
|
|
23
|
+
Protection,
|
|
23
24
|
Risk,
|
|
24
25
|
Scoring,
|
|
25
26
|
Universe,
|
|
@@ -38,6 +39,7 @@ __all__ = [
|
|
|
38
39
|
"Answers",
|
|
39
40
|
"ExitSignals",
|
|
40
41
|
"Indicator",
|
|
42
|
+
"Protection",
|
|
41
43
|
"Risk",
|
|
42
44
|
"Scoring",
|
|
43
45
|
"Universe",
|
|
@@ -90,6 +90,31 @@ class ExitSignals(BaseModel):
|
|
|
90
90
|
min_divergence: float = 0.0 # per-window vote noise gate (0.0 = no-op); relative_value only
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
class Protection(BaseModel):
|
|
94
|
+
"""The MATERIALIZED exit-protection spec — the FULL resolved protection carried IN the graph
|
|
95
|
+
(F1 "the graph IS the spec"). Before F1 the exit values were resolved from a named preset only
|
|
96
|
+
at EMIT time, invisible in the graph and un-editable (a hand-edit of the emitted runtime.yaml
|
|
97
|
+
breaks the content-addressed install hash). Now ``composer new`` RESOLVES the seeding preset's
|
|
98
|
+
``dsl_preset`` (time-cuts + phase1 floor + the phase2 tier ladder) and the risk-overridden
|
|
99
|
+
``guard_rails`` into ``metadata.spec.protection``, so every tunable exit knob lives in the graph
|
|
100
|
+
and survives regeneration. Edit it there, then ``composer check`` (which re-validates the
|
|
101
|
+
ladder through the LIVE Zod+DSL oracle over the emitted runtime.yaml) + ``deploy``.
|
|
102
|
+
|
|
103
|
+
``seeded_from`` is PROVENANCE only — the preset name that seeded these values at generation
|
|
104
|
+
(an edit does not change it; it never re-resolves from the preset). ``dsl_preset`` and
|
|
105
|
+
``guard_rails`` are kept as plain dicts, NOT re-typed here: the single authority on ladder
|
|
106
|
+
validity is the live oracle over the emitted runtime.yaml (a re-typed twin would drift from the
|
|
107
|
+
real Zod schema). A structurally-malformed hand-edit is caught pure-Python (CMP124) before emit;
|
|
108
|
+
a value-level one (tier order, ranges, breaches) is caught by the oracle (CMP110)."""
|
|
109
|
+
|
|
110
|
+
model_config = ConfigDict(extra="forbid")
|
|
111
|
+
|
|
112
|
+
seeded_from: str
|
|
113
|
+
interval_seconds: int = 60
|
|
114
|
+
dsl_preset: dict = Field(default_factory=dict)
|
|
115
|
+
guard_rails: dict = Field(default_factory=dict)
|
|
116
|
+
|
|
117
|
+
|
|
93
118
|
class Universe(BaseModel):
|
|
94
119
|
"""How the tradeable set is chosen. A superset of the corpus shapes; each archetype
|
|
95
120
|
reads the fields it needs (D4 — the answer stays declarative, the template binds it):
|
|
@@ -196,6 +221,14 @@ class Answers(BaseModel):
|
|
|
196
221
|
unmanaged: bool = False # explicit opt-out: no exit section (else CMP120 RED at check)
|
|
197
222
|
risk: Risk = Field(default_factory=Risk)
|
|
198
223
|
edge: str = "" # the user's EDGE, verbatim; the machine never interprets it
|
|
224
|
+
# F1 — the MATERIALIZED protection spec + a free-prose thesis, both carried in metadata.spec.
|
|
225
|
+
# ``protection`` is None on directly-constructed answers (byte-identical to before) and is set by
|
|
226
|
+
# `composer new` (generate._materialize_protection) so the resolved exit ladder lives IN the graph
|
|
227
|
+
# and survives regeneration + edit. ``thesis`` is an optional one-line intent summary generation
|
|
228
|
+
# derives from the edge (never blocking). Both emit into metadata.spec ONLY when set (round-trip
|
|
229
|
+
# discipline: an answers object without them round-trips byte-identically).
|
|
230
|
+
protection: Optional[Protection] = None
|
|
231
|
+
thesis: str = ""
|
|
199
232
|
requires: tuple[RequiredCapability, ...] = ()
|
|
200
233
|
# F1 — OPTIONAL expressiveness surface. Absent (the defaults below) => byte-identical to
|
|
201
234
|
# the pre-F1 output; present => the archetype re-selects gates/weights (scoring) and adds
|
|
@@ -243,6 +276,9 @@ class Answers(BaseModel):
|
|
|
243
276
|
block: dict = {
|
|
244
277
|
"archetype": self.archetype,
|
|
245
278
|
"strategy": self.strategy,
|
|
279
|
+
# F1: a one-line intent thesis, emitted ONLY when generation derived one (round-trip
|
|
280
|
+
# discipline — an answers object with thesis="" carries no key and round-trips as before).
|
|
281
|
+
**({"thesis": self.thesis} if self.thesis else {}),
|
|
246
282
|
"universe": {
|
|
247
283
|
"whitelist": list(self.universe.whitelist),
|
|
248
284
|
"long_basket": list(self.universe.long_basket),
|
|
@@ -256,8 +292,25 @@ class Answers(BaseModel):
|
|
|
256
292
|
"intervals": list(self.intervals),
|
|
257
293
|
"max_positions": self.max_positions,
|
|
258
294
|
"ttl_seconds": self.ttl_seconds,
|
|
259
|
-
"exit_preset": self.exit_preset, # LIVE: the emitted runtime.yaml exit preset
|
|
295
|
+
"exit_preset": self.exit_preset, # LIVE: the emitted runtime.yaml exit preset (SEED)
|
|
260
296
|
"unmanaged": self.unmanaged, # explicit protection opt-out
|
|
297
|
+
# F1: the MATERIALIZED protection block — the resolved exit ladder + guard rails carried
|
|
298
|
+
# IN the graph so they are visible + editable + survive regeneration. Emitted ONLY when
|
|
299
|
+
# set (a directly-constructed answers has protection=None and round-trips byte-identically;
|
|
300
|
+
# `composer new` materializes it — generate._materialize_protection). runtime_emit reads
|
|
301
|
+
# this when present; the preset is only the SEED at generation time.
|
|
302
|
+
**(
|
|
303
|
+
{
|
|
304
|
+
"protection": {
|
|
305
|
+
"seeded_from": self.protection.seeded_from,
|
|
306
|
+
"interval_seconds": self.protection.interval_seconds,
|
|
307
|
+
"dsl_preset": self.protection.dsl_preset,
|
|
308
|
+
"guard_rails": self.protection.guard_rails,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if self.protection is not None
|
|
312
|
+
else {}
|
|
313
|
+
),
|
|
261
314
|
"risk": {
|
|
262
315
|
"margin_pct": self.risk.margin_pct,
|
|
263
316
|
"leverage": self.risk.leverage,
|
|
@@ -89,6 +89,7 @@ def build(answers: Answers) -> GraphBuilder:
|
|
|
89
89
|
fn=_common.CLASSIFIER_FN,
|
|
90
90
|
tests=_common.classifier_tests(_MIN_SCORE),
|
|
91
91
|
header=("pure_fn classifier — THE EDGE SLOT (edit the body; see the EDGE block above).",),
|
|
92
|
+
title="direction classifier from 4h/1h trend + smart-money lean",
|
|
92
93
|
)
|
|
93
94
|
b.set_edge_anchor("classify")
|
|
94
95
|
|
|
@@ -337,6 +337,7 @@ def build(answers: Answers) -> GraphBuilder:
|
|
|
337
337
|
min_divergence),
|
|
338
338
|
header=("pure_fn signed-net-score comparison — THE EDGE SLOT (edit the body; see the EDGE",
|
|
339
339
|
"block above). TWO outputs: entry_scores (entry tail) + exit_scores (exit tail).",),
|
|
340
|
+
title="signed net-score: target vs benchmark across lookbacks",
|
|
340
341
|
)
|
|
341
342
|
b.set_edge_anchor("score")
|
|
342
343
|
|
|
@@ -84,6 +84,7 @@ def build(answers: Answers) -> GraphBuilder:
|
|
|
84
84
|
fn=_common.THESIS_FN,
|
|
85
85
|
tests=_common.thesis_tests(list(u.long_basket), list(u.short_basket), _MIN_SCORE),
|
|
86
86
|
header=("pure_fn confirmation scorer — THE EDGE SLOT (edit the body; see the EDGE block above).",),
|
|
87
|
+
title="per-leg thesis confirmation score",
|
|
87
88
|
)
|
|
88
89
|
b.set_edge_anchor("score")
|
|
89
90
|
|
|
@@ -41,6 +41,7 @@ class BuiltNode:
|
|
|
41
41
|
pure_params: tuple[dict, ...] = ()
|
|
42
42
|
fn: Optional[str] = None
|
|
43
43
|
tests: tuple[dict, ...] = ()
|
|
44
|
+
title: Optional[str] = None # pure_fn OPTIONAL one-line title (one semantic unit)
|
|
44
45
|
# presentation
|
|
45
46
|
header: tuple[str, ...] = () # comment lines emitted above the node
|
|
46
47
|
param_comments: dict = field(default_factory=dict) # param -> trailing comment
|
|
@@ -117,9 +118,13 @@ class GraphBuilder:
|
|
|
117
118
|
fn: str,
|
|
118
119
|
tests: tuple[dict, ...] = (),
|
|
119
120
|
header: tuple[str, ...] = (),
|
|
121
|
+
title: Optional[str] = None,
|
|
120
122
|
) -> "GraphBuilder":
|
|
121
123
|
"""Add a per-graph pure_fn node (D2 kind 2). Its declared ports must use the closed
|
|
122
|
-
PortType vocabulary — a bad type is a ``ValueError`` (CI catches template drift).
|
|
124
|
+
PortType vocabulary — a bad type is a ``ValueError`` (CI catches template drift).
|
|
125
|
+
|
|
126
|
+
``title`` is the OPTIONAL one-line "one titled semantic unit" label. Generation SHOULD
|
|
127
|
+
pass it for every pure_fn it emits (a missing title is a non-blocking check annotation)."""
|
|
123
128
|
for side, ports in (("input", inputs), ("output", outputs)):
|
|
124
129
|
for p in ports:
|
|
125
130
|
try:
|
|
@@ -139,6 +144,7 @@ class GraphBuilder:
|
|
|
139
144
|
fn=fn,
|
|
140
145
|
tests=tuple(tests),
|
|
141
146
|
header=tuple(header),
|
|
147
|
+
title=title,
|
|
142
148
|
)
|
|
143
149
|
)
|
|
144
150
|
return self
|
|
@@ -158,6 +158,10 @@ def _emit_node(bn: BuiltNode) -> list[str]:
|
|
|
158
158
|
|
|
159
159
|
if bn.kind == "pure_fn":
|
|
160
160
|
lines.append(" kind: pure_fn")
|
|
161
|
+
if bn.title:
|
|
162
|
+
# OPTIONAL one-line title (one titled semantic unit); emitted only when set so
|
|
163
|
+
# title-less pure_fns round-trip byte-identically.
|
|
164
|
+
lines.append(f" title: {_dq(bn.title)}")
|
|
161
165
|
lines.append(" inputs:")
|
|
162
166
|
for p in bn.inputs:
|
|
163
167
|
lines.append(f" - {_inline_port(p)}")
|
|
@@ -199,13 +203,15 @@ def render(builder: GraphBuilder, answers: Answers, banner: list[str]) -> str:
|
|
|
199
203
|
lines.append("version: 1")
|
|
200
204
|
lines.append("")
|
|
201
205
|
|
|
202
|
-
# --- metadata (D7: the spec file IS the state —
|
|
206
|
+
# --- metadata (D7: the spec file IS the state — the interview spec lives under `spec`; F1
|
|
207
|
+
# renamed the block `answers` -> `spec` since it now carries the MATERIALIZED protection too.
|
|
208
|
+
# The loader still reads a legacy `answers` block for back-compat) ---
|
|
203
209
|
lines.append("metadata:")
|
|
204
210
|
meta = {
|
|
205
211
|
"archetype": answers.archetype,
|
|
206
212
|
"strategy": answers.strategy,
|
|
207
213
|
"generated": {"by": "composer new", "content_hash": _HASH_PLACEHOLDER},
|
|
208
|
-
"
|
|
214
|
+
"spec": answers.metadata_block(),
|
|
209
215
|
}
|
|
210
216
|
for k, v in meta.items():
|
|
211
217
|
lines.extend(_emit_kv(k, v, 2))
|
|
@@ -21,7 +21,7 @@ from pathlib import Path
|
|
|
21
21
|
from typing import Optional
|
|
22
22
|
|
|
23
23
|
from . import emit
|
|
24
|
-
from .answers import Answers, defaulted_interview_fields
|
|
24
|
+
from .answers import Answers, Protection, defaulted_interview_fields
|
|
25
25
|
from .archetypes import ARCHETYPES, SKELETON
|
|
26
26
|
from .gaps import GapReport, detect_gaps
|
|
27
27
|
|
|
@@ -73,6 +73,50 @@ def _bind_protection_default(answers: Answers) -> Answers:
|
|
|
73
73
|
return answers
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
def _materialize_protection(answers: Answers) -> Answers:
|
|
77
|
+
"""F1 — MATERIALIZE the resolved protection into the graph spec (the graph IS the spec).
|
|
78
|
+
|
|
79
|
+
Before F1 the exit ladder was resolved from a named preset only at EMIT time — invisible in the
|
|
80
|
+
graph, un-editable (a hand-edit of the emitted runtime.yaml breaks the content-addressed install
|
|
81
|
+
hash). Now generation writes the FULL resolved block into ``metadata.spec.protection``: the
|
|
82
|
+
seeding preset's ``dsl_preset`` (deep-copied so a later graph edit never mutates the shared
|
|
83
|
+
preset) and the guard_rails resolved through the SAME helper the emit path uses (byte-identity),
|
|
84
|
+
with ``seeded_from`` recording the provenance preset. Only a PROTECTED unit materializes — an
|
|
85
|
+
unmanaged opt-out has no exit block, and an explicit ``protection`` (a re-generation over an
|
|
86
|
+
already-materialized answers) is left untouched. Returns a (possibly) updated copy."""
|
|
87
|
+
import copy
|
|
88
|
+
|
|
89
|
+
from .presets import EXIT_PRESETS
|
|
90
|
+
from .runtime_emit import resolve_emit_guard_rails
|
|
91
|
+
|
|
92
|
+
if answers.unmanaged or answers.exit_preset is None or answers.protection is not None:
|
|
93
|
+
return answers
|
|
94
|
+
preset = EXIT_PRESETS[answers.exit_preset]
|
|
95
|
+
protection = Protection(
|
|
96
|
+
seeded_from=preset.name,
|
|
97
|
+
interval_seconds=preset.interval_seconds,
|
|
98
|
+
dsl_preset=copy.deepcopy(preset.dsl_preset),
|
|
99
|
+
guard_rails=resolve_emit_guard_rails(answers, preset),
|
|
100
|
+
)
|
|
101
|
+
return answers.model_copy(update={"protection": protection})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _derive_thesis(answers: Answers) -> Answers:
|
|
105
|
+
"""F1 — write a short, deterministic intent thesis into the graph spec when none was supplied.
|
|
106
|
+
|
|
107
|
+
Derived from the interview intent (the verbatim edge, normalized to one line and capped); when
|
|
108
|
+
there is no edge, a generic archetype descriptor. Purely descriptive graph metadata — it never
|
|
109
|
+
reaches the runtime.yaml and never blocks generation. An explicit thesis is left untouched."""
|
|
110
|
+
if answers.thesis:
|
|
111
|
+
return answers
|
|
112
|
+
edge = " ".join((answers.edge or "").split()).strip()
|
|
113
|
+
if edge:
|
|
114
|
+
thesis = edge if len(edge) <= 200 else edge[:197].rstrip() + "..."
|
|
115
|
+
else:
|
|
116
|
+
thesis = f"A {answers.archetype} strategy ({answers.strategy})."
|
|
117
|
+
return answers.model_copy(update={"thesis": thesis})
|
|
118
|
+
|
|
119
|
+
|
|
76
120
|
def _fmt_default_value(value) -> str:
|
|
77
121
|
"""Render a baked default value for the DEFAULTED echo: ``None`` (a preset-default knob like
|
|
78
122
|
drawdown_halt_pct) reads as ``none`` so the reader sees it was never offered."""
|
|
@@ -107,6 +151,8 @@ def _defaulted_message(lines: list[str]) -> str:
|
|
|
107
151
|
def render_graph(answers: Answers) -> tuple[str, bool]:
|
|
108
152
|
"""Build + render the graph for these answers; returns (yaml_text, used_skeleton)."""
|
|
109
153
|
answers = _bind_protection_default(answers)
|
|
154
|
+
answers = _materialize_protection(answers) # F1: resolved protection INTO the graph spec
|
|
155
|
+
answers = _derive_thesis(answers) # F1: a one-line intent thesis in the graph spec
|
|
110
156
|
module = ARCHETYPES.get(answers.archetype)
|
|
111
157
|
used_skeleton = module is None
|
|
112
158
|
if used_skeleton:
|
|
@@ -6,12 +6,14 @@ The composer no longer stages a bare scanner (entry-less, exit-less — the inco
|
|
|
6
6
|
(NOT nodes in the DAG — they are consumed by the DslPlugin / ActionRegistry at runtime boot,
|
|
7
7
|
a different lifecycle than the ``nodes``/``edges`` compiled into ``scan.py``; design §6).
|
|
8
8
|
|
|
9
|
-
Everything derives from the graph's ``metadata.
|
|
10
|
-
artifacts share, D7
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
Everything derives from the graph's ``metadata.spec`` (the single source of truth both
|
|
10
|
+
artifacts share, D7; the legacy ``metadata.answers`` key is still read for back-compat), so
|
|
11
|
+
recompiling regenerates ``scan.py`` AND ``runtime.yaml`` byte-identically. The exit ``dsl_preset``
|
|
12
|
+
(time-cuts + phase1 floor + the phase2 tier ladder) and ``risk.guard_rails`` are rendered from
|
|
13
|
+
``spec.protection`` when the graph carries it (F1 — the graph IS the spec; the emitted file is
|
|
14
|
+
INSPECT-ONLY build output). An older graph with no materialized ``spec.protection`` falls back to
|
|
15
|
+
resolving them live from the named preset (byte-identical). To tune protection, edit
|
|
16
|
+
``spec.protection`` in the GRAPH, then ``composer check`` + ``deploy`` — never hand-edit this file.
|
|
15
17
|
|
|
16
18
|
``unmanaged=True`` emits NO ``exit`` section plus a loud warning banner — the deliberate,
|
|
17
19
|
explicit opt-out of protection (a graph that reaches here with ``unmanaged=False`` and no
|
|
@@ -42,6 +44,28 @@ _UNMANAGED_BANNER = [
|
|
|
42
44
|
]
|
|
43
45
|
|
|
44
46
|
|
|
47
|
+
def resolve_emit_guard_rails(answers: Answers, preset) -> dict:
|
|
48
|
+
"""The guard_rails dict the runtime.yaml ``risk:`` section renders — the preset's guard_rails
|
|
49
|
+
with the answers ``risk.*`` overrides layered on, plus the signal-exit-derived per-asset cooldown
|
|
50
|
+
(an explicit risk override always wins; else a signal-exit pair supplies its reentry cooldown;
|
|
51
|
+
else absent). Extracted so BOTH generation (materialize into ``spec.protection.guard_rails``) and
|
|
52
|
+
the emit-time fallback (an older graph with no materialized protection) compute the SAME dict —
|
|
53
|
+
the guarantee that a freshly-materialized graph emits a BYTE-IDENTICAL runtime.yaml."""
|
|
54
|
+
per_asset_cooldown = answers.risk.per_asset_cooldown_seconds
|
|
55
|
+
if per_asset_cooldown is None and answers.exit_signals is not None:
|
|
56
|
+
per_asset_cooldown = answers.exit_signals.reentry_cooldown_seconds
|
|
57
|
+
return resolve_guard_rails(
|
|
58
|
+
preset,
|
|
59
|
+
{
|
|
60
|
+
"drawdown_halt_pct": answers.risk.drawdown_halt_pct,
|
|
61
|
+
"max_consecutive_losses": answers.risk.max_consecutive_losses,
|
|
62
|
+
"cooldown_seconds": answers.risk.cooldown_seconds,
|
|
63
|
+
"max_entries_per_day": answers.risk.max_entries_per_day,
|
|
64
|
+
"per_asset_cooldown_seconds": per_asset_cooldown,
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
45
69
|
def _env_wallet(strategy: str) -> str:
|
|
46
70
|
slug = re.sub(r"[^0-9a-zA-Z]+", "_", strategy).strip("_").upper() or "STRATEGY"
|
|
47
71
|
return "${" + f"{slug}_WALLET" + "}"
|
|
@@ -121,12 +145,15 @@ def render_runtime_yaml(answers: Answers) -> str:
|
|
|
121
145
|
lines.append(f"# {b}")
|
|
122
146
|
elif has_exit:
|
|
123
147
|
preset = EXIT_PRESETS[answers.exit_preset]
|
|
148
|
+
seeded = answers.protection.seeded_from if answers.protection is not None else answers.exit_preset
|
|
124
149
|
lines.append(f"# {strategy} — complete standalone strategy runtime (composer deploy).")
|
|
125
150
|
lines.append("#")
|
|
126
|
-
lines.append("# PROTECTED BY DEFAULT: the exit:
|
|
127
|
-
lines.append(f"# '
|
|
151
|
+
lines.append("# PROTECTED BY DEFAULT: the exit: / risk: sections below are BUILD OUTPUT, rendered")
|
|
152
|
+
lines.append(f"# from the graph's metadata.spec.protection (seeded from preset '{seeded}').")
|
|
128
153
|
lines.append("# A standalone runtime in its own funded wallet; ops mints the wallet + installs")
|
|
129
|
-
lines.append("# (one wallet per instance).
|
|
154
|
+
lines.append("# (one wallet per instance). This file is INSPECT-ONLY: to tune protection, edit")
|
|
155
|
+
lines.append("# spec.protection in the graph, then re-run composer check + deploy + update. Do")
|
|
156
|
+
lines.append("# NOT hand-edit this file — a content-addressed install verifies its hash.")
|
|
130
157
|
else:
|
|
131
158
|
# exit_preset unset AND not opted out — the CMP120 contradiction (check flags it RED).
|
|
132
159
|
lines.append(f"# {strategy} — runtime with NO exit preset set and NO unmanaged opt-out.")
|
|
@@ -215,14 +242,23 @@ def render_runtime_yaml(answers: Answers) -> str:
|
|
|
215
242
|
lines.append(f" execution_timeout_seconds: {_ENTRY_EXECUTION_TIMEOUT}")
|
|
216
243
|
lines.append("")
|
|
217
244
|
|
|
218
|
-
# --- exit: the resolved DSL
|
|
245
|
+
# --- exit: the resolved DSL protection (materialized from metadata.spec.protection when present;
|
|
246
|
+
# else seeded live from the named preset — an older graph with no materialized block). The values
|
|
247
|
+
# are BUILD OUTPUT; the editable source is spec.protection in the graph, not this file. ---
|
|
219
248
|
if has_exit:
|
|
220
249
|
preset = EXIT_PRESETS[answers.exit_preset]
|
|
221
|
-
|
|
222
|
-
|
|
250
|
+
if answers.protection is not None:
|
|
251
|
+
dp = answers.protection.dsl_preset
|
|
252
|
+
exit_interval = answers.protection.interval_seconds
|
|
253
|
+
seeded_from = answers.protection.seeded_from
|
|
254
|
+
else:
|
|
255
|
+
dp = preset.dsl_preset
|
|
256
|
+
exit_interval = preset.interval_seconds
|
|
257
|
+
seeded_from = preset.name
|
|
258
|
+
lines.append(f"# EXIT — from spec.protection (seeded '{seeded_from}': {preset.intent})")
|
|
223
259
|
lines.append("exit:")
|
|
224
260
|
lines.append(" engine: dsl")
|
|
225
|
-
lines.append(f" interval_seconds: {
|
|
261
|
+
lines.append(f" interval_seconds: {exit_interval}")
|
|
226
262
|
lines.append(" order_type: FEE_OPTIMIZED_LIMIT")
|
|
227
263
|
lines.append(" fee_optimized_limit_options:")
|
|
228
264
|
lines.append(" ensure_execution_as_taker: true") # exit closes MUST happen
|
|
@@ -240,17 +276,16 @@ def render_runtime_yaml(answers: Answers) -> str:
|
|
|
240
276
|
lines.append(f" enabled: {_num(p1['enabled'])}")
|
|
241
277
|
lines.append(
|
|
242
278
|
f" max_loss_pct: {_num(p1['max_loss_pct'])}"
|
|
243
|
-
" #
|
|
279
|
+
" # from spec.protection.dsl_preset.phase1.max_loss_pct (edit in the graph, not here)."
|
|
244
280
|
)
|
|
245
281
|
lines.append(f" retrace_threshold: {_num(p1['retrace_threshold'])}")
|
|
246
282
|
lines.append(f" consecutive_breaches_required: {_num(p1['consecutive_breaches_required'])}")
|
|
247
283
|
p2 = dp["phase2"]
|
|
248
284
|
lines.append(" phase2:")
|
|
249
285
|
lines.append(f" enabled: {_num(p2['enabled'])}")
|
|
250
|
-
lines.append(" tiers: #
|
|
251
|
-
for
|
|
252
|
-
|
|
253
|
-
lines.append(f" - {_tier_inline(tier)}{note}")
|
|
286
|
+
lines.append(" tiers: # from spec.protection.dsl_preset.phase2.tiers (edit in the graph).")
|
|
287
|
+
for tier in p2["tiers"]:
|
|
288
|
+
lines.append(f" - {_tier_inline(tier)}")
|
|
254
289
|
lines.append("")
|
|
255
290
|
|
|
256
291
|
# --- risk.guard_rails: the automated pause / circuit breaker (design §4) ---
|
|
@@ -260,19 +295,16 @@ def render_runtime_yaml(answers: Answers) -> str:
|
|
|
260
295
|
# signal-exit pair supplies exit_signals.reentry_cooldown_seconds. None (exit-less, no override)
|
|
261
296
|
# => absent from guard_rails => byte-identical to today. resolve_guard_rails only adds a key
|
|
262
297
|
# whose value is not None, so the emit loop below stays silent for the exit-less case.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
"per_asset_cooldown_seconds": per_asset_cooldown,
|
|
274
|
-
},
|
|
275
|
-
)
|
|
298
|
+
# A materialized protection block carries the ALREADY-resolved guard_rails (generation ran the
|
|
299
|
+
# SAME resolve_emit_guard_rails, so this is byte-identical); an older graph with no materialized
|
|
300
|
+
# block falls back to resolving live from the seeding preset. unmanaged (has_exit False, never
|
|
301
|
+
# materialized) always falls back — the drawdown circuit breaker ships from the default preset.
|
|
302
|
+
if answers.protection is not None and has_exit:
|
|
303
|
+
guard_rails = answers.protection.guard_rails
|
|
304
|
+
else:
|
|
305
|
+
guard_rails = resolve_emit_guard_rails(
|
|
306
|
+
answers, EXIT_PRESETS[answers.exit_preset or DEFAULT_PRESET]
|
|
307
|
+
)
|
|
276
308
|
lines.append("# RISK guard rails — the automated pause / circuit breaker (composer owns policy; ops owns manual stop).")
|
|
277
309
|
lines.append("risk:")
|
|
278
310
|
lines.append(f" data_retention_seconds: {_DATA_RETENTION_SECONDS}")
|
|
@@ -298,13 +330,17 @@ def answers_from_metadata(metadata: dict) -> Answers:
|
|
|
298
330
|
from). Used by ``check``/``deploy`` to emit runtime.yaml from the graph.
|
|
299
331
|
|
|
300
332
|
Two shapes are accepted:
|
|
301
|
-
* ``composer new`` output carries a full ``metadata.
|
|
333
|
+
* ``composer new`` output carries a full ``metadata.spec`` block -> construct from it.
|
|
334
|
+
BACK-COMPAT: an older graph carries the same block under the legacy key ``metadata.answers``
|
|
335
|
+
(the writer now always emits ``spec``); it is read identically when ``spec`` is absent.
|
|
302
336
|
* A hand-authored example carries only top-level ``metadata`` keys (archetype, strategy,
|
|
303
337
|
and optionally ``exit_preset`` / ``unmanaged``) -> construct a minimal Answers, letting
|
|
304
338
|
the exit_preset default to protected_standard (PROTECTION BY DEFAULT).
|
|
305
339
|
"""
|
|
306
340
|
metadata = metadata or {}
|
|
307
|
-
block = metadata.get("
|
|
341
|
+
block = metadata.get("spec")
|
|
342
|
+
if not (isinstance(block, dict) and block):
|
|
343
|
+
block = metadata.get("answers") # BACK-COMPAT: legacy `answers` key on older graphs
|
|
308
344
|
if isinstance(block, dict) and block:
|
|
309
345
|
return Answers(**block)
|
|
310
346
|
kwargs: dict = {
|
|
@@ -4,9 +4,11 @@ Schema version: 1. Generated from composer/registry/nodes.py — do not edit by
|
|
|
4
4
|
|
|
5
5
|
Progressive-disclosure index (D4): node names, one-liners, param summaries. `composer describe <node>` gives full ports/params/examples.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## compute
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Compute is yours to write: a `kind: pure_fn` node — typed inputs in, ONE titled semantic unit out, inline tests. That is the first-class way to express any indicator / score / classification / normalization math (`hard_gates_all` covers simple gate-and-weigh). Wire sources, stateful nodes, and emitters from the catalog below. Run `composer describe pure_fn` for the authoring contract + reference formulas.
|
|
10
|
+
|
|
11
|
+
A curated library of common indicator formulas (sma, rsi, pct_change, trend_structure, …) also exists: add one BY NAME under an answers `indicators:` entry when a formula fits EXACTLY. Run `composer describe indicators` to list them (`composer describe <name>` for one) — prefer a `pure_fn` unless a curated formula fits exactly.
|
|
10
12
|
|
|
11
13
|
## source
|
|
12
14
|
|
|
@@ -62,28 +64,6 @@ Bespoke scoring/classification is a `kind: pure_fn` node (typed ports, one `def`
|
|
|
62
64
|
- **universe_discover** — Derive a tradeable universe by filtering live instruments on a predicate.
|
|
63
65
|
- params: min_day_volume (float=0.0, 0.0..); max_leverage_cap (float=5.0, 1.0..); name_prefix (str='')
|
|
64
66
|
|
|
65
|
-
## indicator
|
|
66
|
-
|
|
67
|
-
- **acceleration** — Whether a short-window move is at least half a long-window move.
|
|
68
|
-
- params: short_window (int=3, 1..); long_window (int=6, 2..); interval (str='4h')
|
|
69
|
-
- **aggregate_bias** — Cross-trader net/gross cohort bias per coin.
|
|
70
|
-
- **breakout_detect** — Directional breakout of the prior-window high/low.
|
|
71
|
-
- params: window (int=168, 2..); interval (str='1h')
|
|
72
|
-
- **pct_change** — Percent change of close over a lookback window.
|
|
73
|
-
- params: lookback (int=42, 1..); interval (str='4h')
|
|
74
|
-
- **recent_high_bars_ago** — Bars elapsed since the highest high in a window (fresh-high check).
|
|
75
|
-
- params: window (int=60, 2..); interval (str='4h')
|
|
76
|
-
- **rsi** — Relative Strength Index over the last N bars.
|
|
77
|
-
- params: period (int=14, 2..100); interval (str='1h')
|
|
78
|
-
- **sma** — Simple moving average of close and an above/below flag.
|
|
79
|
-
- params: period (int=200, 1..1000); interval (str='4h')
|
|
80
|
-
- **threshold_gate** — Compare a per-asset feature against a threshold, emitting a 1.0/0.0 gate.
|
|
81
|
-
- params: op (str='lt'); value (float=0.0)
|
|
82
|
-
- **trend_structure** — Higher-lows / lower-highs trend direction over a lookback.
|
|
83
|
-
- params: lookback (int=6, 2..); interval (str='4h')
|
|
84
|
-
- **volume_surge_ratio** — Recent-window mean volume divided by a baseline-window mean.
|
|
85
|
-
- params: recent_window (int=6, 1..); baseline_window (int=42, 1..); interval (str='4h')
|
|
86
|
-
|
|
87
67
|
## filter
|
|
88
68
|
|
|
89
69
|
- **classifier_predicate** — Gate universe membership on a set of instrument-field predicates.
|
|
@@ -701,6 +701,8 @@ with:
|
|
|
701
701
|
long_window: 6
|
|
702
702
|
```
|
|
703
703
|
|
|
704
|
+
> Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
|
|
705
|
+
|
|
704
706
|
## aggregate_bias (indicator)
|
|
705
707
|
|
|
706
708
|
The ONE sanctioned promote-loop primitive (fn->primitive): the cross-trader net/gross bias per coin, shared near-verbatim by whalehunter and the chimp/gibbon/gorilla/orangutan cohort packages. Takes a record_list of TraderState records (from trader_states_fetch — each row's openPositions[] carrying coin, szi, positionValue/entry_px) and sums each coin's SIGNED notional across every trader, emitting a per-coin feature_map: {net, gross, bias, n_long, n_short, direction} where bias = net/gross in [-1,+1] (+1 all long, -1 all short) and direction = LONG|SHORT|NEUTRAL from the sign of net. A feature_map (the indicator output contract) rather than a scorer's score_map, so kind=indicator; its non-candle record_list input mirrors threshold_gate's non-candle feature_map input.
|
|
@@ -723,6 +725,8 @@ Example:
|
|
|
723
725
|
node: aggregate_bias
|
|
724
726
|
```
|
|
725
727
|
|
|
728
|
+
> Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
|
|
729
|
+
|
|
726
730
|
## breakout_detect (indicator)
|
|
727
731
|
|
|
728
732
|
hawk's breakout: LONG if the latest close breaks the prior-`window` high, SHORT if it breaks the low, else NEUTRAL.
|
|
@@ -747,6 +751,8 @@ with:
|
|
|
747
751
|
interval: "1h"
|
|
748
752
|
```
|
|
749
753
|
|
|
754
|
+
> Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
|
|
755
|
+
|
|
750
756
|
## pct_change (indicator)
|
|
751
757
|
|
|
752
758
|
Percent change of close over `lookback` bars (stag 7d trend %; bison/thesis-fund price momentum — same shape, different window).
|
|
@@ -771,6 +777,8 @@ with:
|
|
|
771
777
|
interval: "4h"
|
|
772
778
|
```
|
|
773
779
|
|
|
780
|
+
> Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
|
|
781
|
+
|
|
774
782
|
## recent_high_bars_ago (indicator)
|
|
775
783
|
|
|
776
784
|
Counts bars since the highest high within `window` (stag's fresh-high freshness check).
|
|
@@ -794,6 +802,8 @@ with:
|
|
|
794
802
|
window: 60
|
|
795
803
|
```
|
|
796
804
|
|
|
805
|
+
> Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
|
|
806
|
+
|
|
797
807
|
## rsi (indicator)
|
|
798
808
|
|
|
799
809
|
Trailing-window RSI over the last `period` gains/losses (duplicated verbatim in bison and thesis-fund).
|
|
@@ -818,6 +828,8 @@ with:
|
|
|
818
828
|
interval: "1h"
|
|
819
829
|
```
|
|
820
830
|
|
|
831
|
+
> Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
|
|
832
|
+
|
|
821
833
|
## sma (indicator)
|
|
822
834
|
|
|
823
835
|
Simple moving average over `period` closes at `interval`, plus a close>SMA flag (stag SMA-200 trend gate).
|
|
@@ -843,6 +855,8 @@ with:
|
|
|
843
855
|
interval: "4h"
|
|
844
856
|
```
|
|
845
857
|
|
|
858
|
+
> Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
|
|
859
|
+
|
|
846
860
|
## threshold_gate (indicator)
|
|
847
861
|
|
|
848
862
|
Takes a feature_map (a RAW indicator output) and emits 1.0 where value `op` threshold holds, else 0.0 — a truthy/falsy gate the scorer's required_gates can consume. `op` is one of lt|lte|gt|gte. Unlike every other indicator this reads a feature_map, not candles: wire its `values` input from a feature node's output (answers `indicators[].source`). Need demonstrated TWICE in ONE real dev transcript (D2 known-need bar): the 'fresh entry <= 5 bars' gate over recent_high_bars_ago (a truthiness gate INVERTED it — a fresh 0-bars-ago high read falsy and FAILED), and the 'don't chase if own_return_24h >= 15%' runaway filter. A raw-bars-ago-as-hard-gate has no correct truthiness reading; this node is the missing thresholded gate.
|
|
@@ -867,6 +881,8 @@ with:
|
|
|
867
881
|
value: 5
|
|
868
882
|
```
|
|
869
883
|
|
|
884
|
+
> Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
|
|
885
|
+
|
|
870
886
|
## trend_structure (indicator)
|
|
871
887
|
|
|
872
888
|
Classifies LONG (higher lows) / SHORT (lower highs) / NEUTRAL over `lookback` bars. This is the single highest-value dedup target: literally duplicated across hawk, bison (x2), magpie, and thesis-fund — 6 corpus call-sites, one primitive.
|
|
@@ -891,6 +907,8 @@ with:
|
|
|
891
907
|
interval: "4h"
|
|
892
908
|
```
|
|
893
909
|
|
|
910
|
+
> Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
|
|
911
|
+
|
|
894
912
|
## volume_surge_ratio (indicator)
|
|
895
913
|
|
|
896
914
|
Unifies stag's volume_surge, hawk's volume_ratio, and bison's volume_trend into one parameterized primitive: mean(recent_window) / mean(baseline_window) volume.
|
|
@@ -916,6 +934,8 @@ with:
|
|
|
916
934
|
baseline_window: 42
|
|
917
935
|
```
|
|
918
936
|
|
|
937
|
+
> Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
|
|
938
|
+
|
|
919
939
|
## classifier_predicate (filter)
|
|
920
940
|
|
|
921
941
|
magpie's funding-signature IPOP test generalized: a named boolean predicate over instrument fields (funding magnitude, max leverage, delisting). Distinct from universe_discover in that it classifies an existing candidate set rather than deriving one.
|
|
@@ -1963,6 +1963,10 @@
|
|
|
1963
1963
|
},
|
|
1964
1964
|
"type": "array"
|
|
1965
1965
|
},
|
|
1966
|
+
"title": {
|
|
1967
|
+
"description": "pure_fn OPTIONAL one-line title: what this compute unit computes, as one titled semantic unit. Generation writes it; a missing title on a hand-authored pure_fn is a non-blocking `composer check` annotation, never an error.",
|
|
1968
|
+
"type": "string"
|
|
1969
|
+
},
|
|
1966
1970
|
"with": {
|
|
1967
1971
|
"description": "Node parameters.",
|
|
1968
1972
|
"type": "object"
|
|
@@ -91,6 +91,10 @@ class PureFnNode:
|
|
|
91
91
|
fn_src: str
|
|
92
92
|
tests: tuple[dict, ...] = ()
|
|
93
93
|
line: Optional[int] = None
|
|
94
|
+
# OPTIONAL one-line title: what this compute unit computes, as one titled semantic unit.
|
|
95
|
+
# Generation writes it; a missing title is a non-blocking `composer check` annotation
|
|
96
|
+
# (see composer.verbs.check._pure_fn_title_annotations), never a validation error.
|
|
97
|
+
title: Optional[str] = None
|
|
94
98
|
|
|
95
99
|
# -- registry-NodeDef duck surface ------------------------------------------------
|
|
96
100
|
@property
|
|
@@ -166,6 +170,9 @@ def parse_pure_fn(entry: dict, line: Optional[int] = None) -> PureFnNode:
|
|
|
166
170
|
|
|
167
171
|
tests = tuple(t for t in (entry.get("tests") or []) if isinstance(t, dict))
|
|
168
172
|
|
|
173
|
+
title = entry.get("title")
|
|
174
|
+
title = title.strip() if isinstance(title, str) and title.strip() else None
|
|
175
|
+
|
|
169
176
|
return PureFnNode(
|
|
170
177
|
id=node_id,
|
|
171
178
|
inputs=inputs,
|
|
@@ -174,4 +181,5 @@ def parse_pure_fn(entry: dict, line: Optional[int] = None) -> PureFnNode:
|
|
|
174
181
|
fn_src=fn_src,
|
|
175
182
|
tests=tests,
|
|
176
183
|
line=line,
|
|
184
|
+
title=title,
|
|
177
185
|
)
|