@senpi/runtime 3.0.9-dev.feat-strategy-compos.20260716233548 → 3.0.9-dev.feat-strategy-compos.20260717153418

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 (35) hide show
  1. package/dist/composer/authoring/__init__.py +2 -0
  2. package/dist/composer/authoring/answers.py +47 -3
  3. package/dist/composer/authoring/archetypes/__init__.py +19 -2
  4. package/dist/composer/authoring/archetypes/breakout.py +18 -0
  5. package/dist/composer/authoring/archetypes/classifier.py +16 -0
  6. package/dist/composer/authoring/archetypes/relative_value.py +354 -0
  7. package/dist/composer/authoring/archetypes/thesis_fund.py +17 -0
  8. package/dist/composer/authoring/archetypes/trend_momentum.py +16 -0
  9. package/dist/composer/authoring/generate.py +12 -5
  10. package/dist/composer/authoring/presets.py +3 -0
  11. package/dist/composer/authoring/runtime_emit.py +57 -1
  12. package/dist/composer/cli.py +30 -7
  13. package/dist/composer/compiler/__init__.py +25 -4
  14. package/dist/composer/compiler/compile.py +230 -12
  15. package/dist/composer/examples/conviction_scorer.graph.yaml +1 -1
  16. package/dist/composer/examples/pair_relative_strength.graph.yaml +263 -0
  17. package/dist/composer/examples/pre_listing_classifier.graph.yaml +1 -1
  18. package/dist/composer/generated/catalog.md +29 -1
  19. package/dist/composer/generated/describe.md +49 -0
  20. package/dist/composer/generated/graph.schema.json +50 -0
  21. package/dist/composer/primitives/emitters.py +55 -0
  22. package/dist/composer/primitives/sources.py +77 -0
  23. package/dist/composer/primitives/stateful.py +2 -2
  24. package/dist/composer/registry/__init__.py +2 -0
  25. package/dist/composer/registry/generate.py +250 -2
  26. package/dist/composer/registry/models.py +28 -0
  27. package/dist/composer/registry/nodes.py +74 -2
  28. package/dist/composer/templates/scan.py.j2 +2 -1
  29. package/dist/composer/validator/errors.py +4 -0
  30. package/dist/composer/verbs/check.py +31 -7
  31. package/dist/composer/verbs/deploy.py +26 -0
  32. package/dist/composer/verbs/docs.py +60 -4
  33. package/dist/composer/verbs/runtime_validate.py +152 -2
  34. package/package.json +2 -2
  35. package/dist/composer/AUTHORING.md +0 -435
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  from .answers import (
20
20
  Answers,
21
+ ExitSignals,
21
22
  Indicator,
22
23
  Risk,
23
24
  Scoring,
@@ -34,6 +35,7 @@ from .strategy_manifest import render_strategy_yaml
34
35
 
35
36
  __all__ = [
36
37
  "Answers",
38
+ "ExitSignals",
37
39
  "Indicator",
38
40
  "Risk",
39
41
  "Scoring",
@@ -56,6 +56,29 @@ class Risk(BaseModel):
56
56
  max_consecutive_losses: Optional[int] = None # stop after N losses
57
57
  cooldown_seconds: Optional[int] = None # post-loss cooldown (>= 60)
58
58
  max_entries_per_day: Optional[int] = None # per-day entry cap (>= 1)
59
+ # post-close per-asset re-entry cooldown (guard_rails.per_asset_cooldown_seconds, floor 300);
60
+ # None -> preset default, OR (for a signal-exit pair) the exit_signals.reentry_cooldown_seconds
61
+ # default. An explicit value here ALWAYS wins (RISK_OVERRIDE_KEYS carries it into guard_rails).
62
+ per_asset_cooldown_seconds: Optional[int] = None
63
+
64
+
65
+ class ExitSignals(BaseModel):
66
+ """OPTIONAL thesis-exit knobs — the hysteresis dead band + post-close re-entry cooldown for a
67
+ signal-driven (thesis) exit (exit-composition slice). Absent (the default ``None`` on
68
+ :class:`Answers`) => byte-identical output EVERYWHERE (the hard regression constraint): no exit
69
+ scanner, no CLOSE_POSITION action, no per-asset cooldown guard rail. Present => the composer
70
+ additionally emits the exit scanner + CLOSE action pair and the per-asset cooldown (design §1.2).
71
+
72
+ ``interval_seconds`` is the exit scanner's poll cadence; ``enter_threshold`` / ``exit_threshold``
73
+ are the net-score hysteresis band the compiled predicate uses; ``reentry_cooldown_seconds`` is
74
+ the post-close per-asset re-entry cooldown (schema floor 300)."""
75
+
76
+ model_config = ConfigDict(extra="forbid")
77
+
78
+ interval_seconds: int = 300
79
+ enter_threshold: float = 3.0
80
+ exit_threshold: float = 2.0
81
+ reentry_cooldown_seconds: int = 1800
59
82
 
60
83
 
61
84
  class Universe(BaseModel):
@@ -154,9 +177,12 @@ class Answers(BaseModel):
154
177
  intervals: tuple[str, ...] = ("4h",)
155
178
  max_positions: int = 1 # cardinality -> ranker choice (single-best vs pool-cap)
156
179
  ttl_seconds: int = 240 # memory -> ttl_dedup_state dedup window
157
- # LIVE: emitted as the runtime.yaml exit: section. Default None = "unset"; `composer new`
158
- # BINDS protected_standard unless overridden (protection-by-default). A graph reaching
159
- # `check` with exit_preset unset AND unmanaged False is the RED CMP120 contradiction.
180
+ # LIVE: emitted as the runtime.yaml exit: section. Default None = "unset" (unset-vs-explicit
181
+ # semantics): `composer new` RESOLVES an unset preset at build time — protected_standard when
182
+ # there is no exit_signals (today's default, byte-identical), protected_wide when exit_signals
183
+ # is present (the DSL demotes to a WIDE safety net so it never front-runs the thesis exit). An
184
+ # EXPLICIT value ALWAYS wins. A graph reaching `check` with exit_preset unset AND unmanaged
185
+ # False is the RED CMP120 contradiction.
160
186
  exit_preset: Optional[str] = None
161
187
  unmanaged: bool = False # explicit opt-out: no exit section (else CMP120 RED at check)
162
188
  risk: Risk = Field(default_factory=Risk)
@@ -167,6 +193,10 @@ class Answers(BaseModel):
167
193
  # registry indicator nodes (indicators) mechanically, no hand edit. See the two submodels.
168
194
  scoring: Optional[Scoring] = None
169
195
  indicators: tuple[Indicator, ...] = ()
196
+ # exit-composition — OPTIONAL thesis-exit surface. Absent (None) => byte-identical to today
197
+ # (no exit scanner / CLOSE action / per-asset cooldown). Present => the composer emits the
198
+ # entry/exit scanner pair. See :class:`ExitSignals` and the PRESET POLICY on exit_preset.
199
+ exit_signals: Optional[ExitSignals] = None
170
200
 
171
201
  @field_validator("exit_preset")
172
202
  @classmethod
@@ -232,6 +262,11 @@ class Answers(BaseModel):
232
262
  {"name": c.name, "kind": c.kind, "need": c.need} for c in self.requires
233
263
  ],
234
264
  }
265
+ # per_asset_cooldown_seconds joins the risk block ONLY when set — the sibling knobs above
266
+ # always emit (None -> null), but this one is exit-composition-era and must stay ABSENT for
267
+ # exit-less answers to keep the metadata byte-identical (round-trips via Answers(**block)).
268
+ if self.risk.per_asset_cooldown_seconds is not None:
269
+ block["risk"]["per_asset_cooldown_seconds"] = self.risk.per_asset_cooldown_seconds
235
270
  # F1: emit the optional surface ONLY when set, so answers without it round-trip
236
271
  # byte-identically to the pre-F1 metadata block (the HARD regression constraint).
237
272
  # Both survive `Answers(**block)` deterministically (the deploy metadata re-derive path).
@@ -250,6 +285,15 @@ class Answers(BaseModel):
250
285
  "source": i.source}
251
286
  for i in self.indicators
252
287
  ]
288
+ # exit-composition: the thesis-exit knobs, emitted ONLY when set (same F1 discipline) so
289
+ # exit-less answers round-trip byte-identically. Survives Answers(**block) as ExitSignals.
290
+ if self.exit_signals is not None:
291
+ block["exit_signals"] = {
292
+ "interval_seconds": self.exit_signals.interval_seconds,
293
+ "enter_threshold": self.exit_signals.enter_threshold,
294
+ "exit_threshold": self.exit_signals.exit_threshold,
295
+ "reentry_cooldown_seconds": self.exit_signals.reentry_cooldown_seconds,
296
+ }
253
297
  return block
254
298
 
255
299
 
@@ -12,15 +12,32 @@ Each module exposes ``build(answers) -> GraphBuilder`` and a ``BANNER`` header.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- from . import breakout, classifier, skeleton, thesis_fund, trend_momentum
15
+ from . import breakout, classifier, relative_value, skeleton, thesis_fund, trend_momentum
16
16
 
17
17
  ARCHETYPES = {
18
18
  "trend_momentum": trend_momentum,
19
19
  "breakout": breakout,
20
20
  "classifier": classifier,
21
21
  "thesis_fund": thesis_fund,
22
+ "relative_value": relative_value,
22
23
  }
23
24
 
24
25
  SKELETON = skeleton
25
26
 
26
- __all__ = ["ARCHETYPES", "SKELETON"]
27
+
28
+ def archetype_names() -> list[str]:
29
+ """The recognised archetype names (a `describe <archetype>` namespace)."""
30
+ return sorted(ARCHETYPES)
31
+
32
+
33
+ def render_describe_archetype(name: str) -> str | None:
34
+ """The describe reference for ONE archetype (its ``DESCRIBE`` block — the single home for
35
+ the archetype's shape), or None if the name is not a recognised archetype. The skeleton
36
+ fallback is deliberately excluded: "no matching archetype" is its trigger, not a name."""
37
+ module = ARCHETYPES.get(name)
38
+ if module is None:
39
+ return None
40
+ return "\n".join(module.DESCRIBE) + "\n"
41
+
42
+
43
+ __all__ = ["ARCHETYPES", "SKELETON", "archetype_names", "render_describe_archetype"]
@@ -20,6 +20,24 @@ from ..answers import Answers
20
20
  from ..builder import GraphBuilder
21
21
  from . import _common
22
22
 
23
+ DESCRIBE = [
24
+ "# breakout (archetype)",
25
+ "",
26
+ "Thesis shape: a directional range-break (breakout_detect) is the HARD gate; 4h trend",
27
+ "structure, a volume surge, and smart-money lean are weighted contributors; single-best",
28
+ "(or pool-cap) emission with TTL signal-dedup. Universe is a fixed basket OR discovered",
29
+ "each tick from the live instrument board.",
30
+ "",
31
+ "Emits: clearinghouse_state, ttl_dedup_state, (instrument_list + universe_discover when",
32
+ "discovering), held_asset_exclusion, recent_signal_ttl_dedup, candles_fetch, sm_lean,",
33
+ "breakout_detect, trend_structure, volume_surge_ratio, hard_gates_all (THE EDGE slot),",
34
+ "a ranker, signal_envelope.",
35
+ "",
36
+ "Answers that matter: universe.whitelist OR universe.discover (+ dex / name_prefix /",
37
+ "max_leverage_cap / min_day_volume), intervals, max_positions, ttl_seconds, risk.*, and",
38
+ "scoring / indicators (e.g. the four-hard-gate discovery variant).",
39
+ ]
40
+
23
41
  BANNER = [
24
42
  "breakout strategy — machine-generated anchor (composer new).",
25
43
  "",
@@ -15,6 +15,22 @@ from ..answers import Answers
15
15
  from ..builder import GraphBuilder
16
16
  from . import _common
17
17
 
18
+ DESCRIBE = [
19
+ "# classifier (archetype)",
20
+ "",
21
+ "Thesis shape: trend structure at 4h + 1h feeds a per-graph pure_fn CLASSIFIER that fixes",
22
+ "the side and confirms with smart money (magpie pre-listing shape). Universe is discovered",
23
+ "each tick from the live board (funding / leverage / volume signature) or a fixed whitelist.",
24
+ "",
25
+ "Emits: clearinghouse_state, ttl_dedup_state, (instrument_list + classifier_predicate +",
26
+ "universe_discover when discovering), held_asset_exclusion, recent_signal_ttl_dedup,",
27
+ "candles_fetch, sm_lean, trend_structure x2, a `classify` pure_fn (THE EDGE slot — edit the",
28
+ "body), a ranker, signal_envelope.",
29
+ "",
30
+ "Answers that matter: universe.discover (+ dex / name_prefix / max_leverage_cap /",
31
+ "min_day_volume) OR universe.whitelist, intervals, max_positions, ttl_seconds, risk.*.",
32
+ ]
33
+
18
34
  BANNER = [
19
35
  "classifier strategy — machine-generated anchor (composer new).",
20
36
  "",
@@ -0,0 +1,354 @@
1
+ """relative_value — the pairwise cross-asset relative-value shape (design-models §11 family).
2
+
3
+ The routing incident this closes: a pairwise thesis ("HYPE mean-reverts vs BTC — when BTC
4
+ outperforms, buy HYPE") was generated as per-asset ``trend_momentum``, which scores each name
5
+ from its OWN features only and structurally cannot reach across the asset key to compare BTC
6
+ vs HYPE, guaranteeing the edit path. `relative_value` is that missing shape: a FIXED pair
7
+ whose benchmark leg is pinned in the universe (never discovered, never held/dedup-narrowed —
8
+ narrowing could prune the benchmark and silently break the comparison) and whose cross-asset
9
+ comparison lives in ONE two-output signed-net-score pure_fn (D2 kind 2).
10
+
11
+ This builder builds the PAIRWISE member first (design-models §11: `relative_value.pairwise`,
12
+ with `cross_sectional` — long the strongest vs short the weakest of N — the designed
13
+ follow-up member). One pure_fn produces BOTH tails from the same signed net score:
14
+
15
+ * entry_scores — LONG the target when the benchmark has out-returned it (target is
16
+ relatively cheap) by net >= enter_threshold AND the target is NOT already held.
17
+ * exit_scores — CLOSE the target when the thesis has flipped (target now out-returns the
18
+ benchmark) by net <= -exit_threshold AND the target IS held.
19
+
20
+ Between the two thresholds is the hysteresis DEAD BAND (emit nothing) that kills mixed-window
21
+ thrash; held-exclusion lives INSIDE the fn via an ``open_positions`` input (entry only when
22
+ flat, exit only when held) so complementarity is by construction.
23
+
24
+ Exit topology is answer-driven (design-models §11 — orthogonal axis, relative_value defaults
25
+ to entry+exit): when ``answers.exit_signals`` is present the graph carries the exit tail
26
+ (``close_signal_envelope`` wired to exit_scores + positions), the compiler splits it into an
27
+ entry scanner + a reduce-only exit scanner, and the resolved DSL preset demotes to
28
+ ``protected_wide`` (the safety net, never front-running the thesis exit). When it is ABSENT
29
+ the graph is ENTRY-ONLY: it still carries ``open_positions`` and the held-exclusion inside the
30
+ fn (so the entry stream is silenced while held), the fn still computes exit_scores (unwired —
31
+ a single-module compile), no close emitter is added, and protection stays ``protected_standard``
32
+ per the slice-3 policy. (Documented choice: entry-only keeps positions rather than dropping
33
+ held-exclusion — an entry that fires into an already-held pair would double the position, which
34
+ the no-pyramiding default forbids.)
35
+
36
+ Mechanically bound from the answers (D7 step 3): universe.whitelist -> the fixed [target,
37
+ benchmark] pair (FIRST = the traded target, SECOND = the reference benchmark; the least-new-
38
+ surface convention — no new answers field); intervals[0] -> the candle interval AND the
39
+ per-window bar counts (days x bars-per-interval); exit_signals -> the fn's enter/exit
40
+ thresholds and whether the exit tail is emitted. The lookback windows (3d/7d/30d) and their
41
+ weights (1.0/2.0/3.0 — a longer window is a more durable read) are the archetype's shape.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from ..answers import Answers
47
+ from ..builder import GraphBuilder
48
+ from ._common import GenerationError
49
+
50
+ DESCRIBE = [
51
+ "# relative_value (archetype)",
52
+ "",
53
+ "Thesis shape: a CROSS-ASSET, PAIRWISE relative-value comparison — 'X always beats Y',",
54
+ "'buy the laggard when the benchmark runs ahead'. A FIXED pair [target, benchmark] is fetched",
55
+ "every tick (the benchmark leg is a REFERENCE only — never traded, never pruned); N lookback",
56
+ "returns (3d/7d/30d, longer = heavier weight) feed ONE two-output signed-net-score pure_fn.",
57
+ "The net score votes: benchmark out-returns target -> +weight (target is cheap, a BUY vote);",
58
+ "target out-returns benchmark -> -weight (the thesis flipped, a CLOSE vote).",
59
+ "",
60
+ "The two-scanner exit pair (when answers.exit_signals is set): the SAME pure_fn emits",
61
+ "entry_scores (LONG when net >= enter_threshold and the target is FLAT) and exit_scores",
62
+ "(CLOSE when net <= -exit_threshold and the target is HELD); between them a hysteresis DEAD",
63
+ "BAND emits nothing. Held-exclusion is inside the fn via an open_positions input, so the",
64
+ "entry and exit streams cannot both fire. The composer splits this into an entry scanner",
65
+ "(signal_envelope) and a reduce-only exit scanner (close_signal_envelope) and keeps a WIDE",
66
+ "DSL preset as the always-on safety net (the signal exit is the thesis close, not protection).",
67
+ "Omit exit_signals for an ENTRY-ONLY variant (single module, DSL-managed exits).",
68
+ "",
69
+ "When to use vs trend_momentum (THE ROUTING TEST): if the signal is a RELATION between two",
70
+ "assets ('HYPE vs BTC', 'the laggard of the pair'), it is relative_value — trend_momentum",
71
+ "scores each name from its OWN features and structurally cannot see the other leg (this is",
72
+ "the incident that motivated the family). If the signal is one asset's own trend/momentum in",
73
+ "isolation, it is trend_momentum.",
74
+ "",
75
+ "Members: `pairwise` (this builder — the fixed two-asset pair) is the first; `cross_sectional`",
76
+ "(long the strongest vs short the weakest across N names) is the designed follow-up.",
77
+ "",
78
+ "Emits: clearinghouse_state (equity for sizing — NO held-exclusion on the entry universe),",
79
+ "open_positions (held + direction, for in-fn held-exclusion and the exit direction stamp),",
80
+ "candles_fetch (the fixed pair), pct_change x3 (3d/7d/30d), a `score` pure_fn (THE EDGE slot,",
81
+ "TWO outputs), emit_single_best + signal_envelope (entry tail), and close_signal_envelope",
82
+ "(exit tail — only when exit_signals is set).",
83
+ "",
84
+ "Answers that matter: universe.whitelist = [target, benchmark] (EXACTLY two — no discover),",
85
+ "intervals (the candle interval + window bar counts), exit_signals.{enter_threshold,",
86
+ "exit_threshold, interval_seconds, reentry_cooldown_seconds} (present => the exit tail),",
87
+ "exit_preset (unset => protected_wide with a signal exit, protected_standard entry-only),",
88
+ "risk.*.",
89
+ ]
90
+
91
+ BANNER = [
92
+ "relative_value strategy (pairwise member) — machine-generated anchor (composer new).",
93
+ "",
94
+ "A FIXED pair [target, benchmark] compared cross-asset: N lookback returns (3d/7d/30d) feed",
95
+ "ONE two-output signed-net-score pure_fn. The benchmark leg is a REFERENCE only — always",
96
+ "fetched, never traded, never pruned (narrowing could drop it and break the comparison). The",
97
+ "fn LONGs the target when the benchmark runs ahead (target is cheap) and CLOSEs it when the",
98
+ "pair flips, with a hysteresis dead band and held-exclusion built in. Edit ONLY the pure_fn",
99
+ "body (the EDGE slot) to move this toward your edge; every intermediate state stays valid",
100
+ "(`composer check`).",
101
+ ]
102
+
103
+ # The archetype's shape: three lookback windows in DAYS with their vote weights (a longer
104
+ # window is a more durable read, so it carries more weight). Fixed at three for the pairwise
105
+ # member; the cross_sectional follow-up may vary this. Bar counts are days x bars-per-interval.
106
+ WINDOWS: tuple[tuple[int, float], ...] = ((3, 1.0), (7, 2.0), (30, 3.0))
107
+
108
+ # candle bars per day, per supported interval (Hyperliquid candle grid). Used to turn a window
109
+ # expressed in DAYS into the pct_change `lookback` bar count for the chosen interval.
110
+ _BARS_PER_DAY: dict[str, int] = {
111
+ "1m": 1440, "5m": 288, "15m": 96, "30m": 48,
112
+ "1h": 24, "2h": 12, "4h": 6, "8h": 3, "12h": 2, "1d": 1,
113
+ }
114
+
115
+ _DEFAULT_ENTER = 3.0
116
+ _DEFAULT_EXIT = 2.0
117
+ _SIGNAL_STRENGTH = 1.0
118
+
119
+ # The two-output signed-net-score comparison (D2 kind 2). Generic over the bound pair/weights/
120
+ # thresholds (all passed as params); emitted verbatim AND executed in _relative_value_tests to
121
+ # synthesize inline fixtures that pass by construction. Deterministic, math-only (purity-checked).
122
+ RELATIVE_VALUE_FN = '''\
123
+ def score_relative_value(ret_3d, ret_7d, ret_30d, positions, benchmark, target, w_3d, w_7d, w_30d, enter_threshold, exit_threshold, signal_strength):
124
+ # TODO(edit): the PAIRWISE relative-value edge (a WORKING DEFAULT). For each lookback window
125
+ # compare the TARGET's return to the BENCHMARK's: the benchmark OUT-returning the target means
126
+ # the target is relatively cheap -> +weight (a BUY vote); the target OUT-returning the benchmark
127
+ # means the thesis has FLIPPED -> -weight (a CLOSE vote). The signed weighted sum is the net
128
+ # score. ENTER LONG the target when net >= enter_threshold AND it is NOT already held; CLOSE the
129
+ # target when net <= -exit_threshold AND it IS held; between the thresholds (the hysteresis dead
130
+ # band) emit NOTHING. Held-exclusion lives here (via positions) so entry and exit never both fire.
131
+ # A window missing either leg simply abstains (no vote), never raises. The benchmark is a
132
+ # REFERENCE leg only — never scored, never emitted. Encode YOUR edge by editing this body; it is
133
+ # purity-checked (no ctx/MCP/I/O, math only) and its inline tests run at validate time.
134
+ windows = [(ret_3d, w_3d, "3d"), (ret_7d, w_7d, "7d"), (ret_30d, w_30d, "30d")]
135
+ net = 0.0
136
+ enter_reasons = []
137
+ exit_reasons = []
138
+ for series, weight, label in windows:
139
+ bench_ret = series.get(benchmark)
140
+ target_ret = series.get(target)
141
+ if bench_ret is None or target_ret is None:
142
+ continue
143
+ if bench_ret > target_ret:
144
+ net = net + weight * signal_strength
145
+ enter_reasons.append("bench_outperf_" + label)
146
+ elif target_ret > bench_ret:
147
+ net = net - weight * signal_strength
148
+ exit_reasons.append("target_outperf_" + label)
149
+ held_direction = None
150
+ for position in positions or []:
151
+ if position.get("coin") == target:
152
+ held_direction = position.get("direction") or "LONG"
153
+ break
154
+ is_held = held_direction is not None
155
+ entry_scores = {}
156
+ exit_scores = {}
157
+ if net >= enter_threshold and not is_held:
158
+ entry_scores[target] = {"score": round(net, 4), "direction": "LONG", "reasons": enter_reasons}
159
+ elif net <= -exit_threshold and is_held:
160
+ exit_scores[target] = {"score": round(-net, 4), "direction": held_direction, "reasons": exit_reasons}
161
+ return entry_scores, exit_scores
162
+ '''
163
+
164
+
165
+ def _exec_fn(fn_src: str):
166
+ ns: dict = {}
167
+ exec(fn_src, ns) # noqa: S102 - trusted, in-repo reference body used only for test-gen
168
+ for name, obj in ns.items():
169
+ if callable(obj) and name != "__builtins__":
170
+ return obj
171
+ raise RuntimeError("no function found in reference body")
172
+
173
+
174
+ def _relative_value_tests(
175
+ target: str, benchmark: str, weights: tuple[float, ...], enter: float, exit_threshold: float
176
+ ) -> tuple[dict, ...]:
177
+ """Five inline fixtures whose expected values are computed from the default body itself with
178
+ the BOUND pair / weights / thresholds, so they pass by construction (emitted body == executed
179
+ body). The five semantic cases: all-windows entry, flip exit, dead band, held-exclusion
180
+ suppresses entry, flat-no-exit. Held positions are the open_positions record shape."""
181
+ fn = _exec_fn(RELATIVE_VALUE_FN)
182
+ w3, w7, w30 = weights
183
+ held = [{
184
+ "coin": target, "direction": "LONG", "szi": 1.0,
185
+ "entry_px": 100.0, "unrealized_pnl_pct": 2.5, "margin_used": 10.0,
186
+ }]
187
+
188
+ def call(r3, r7, r30, positions):
189
+ entry, ex = fn(r3, r7, r30, positions, benchmark, target,
190
+ w3, w7, w30, enter, exit_threshold, _SIGNAL_STRENGTH)
191
+ return {"entry_scores": entry, "exit_scores": ex}
192
+
193
+ # bench out-returns target in every window -> max +net.
194
+ all_bench = {benchmark: 5.0, target: 1.0}
195
+ # target out-returns bench in every window -> max -net.
196
+ all_target = {benchmark: 1.0, target: 5.0}
197
+
198
+ cases = (
199
+ ("all_windows_entry",
200
+ all_bench, all_bench, all_bench, []),
201
+ ("thesis_flip_exit_when_held",
202
+ all_target, all_target, all_target, held),
203
+ # +1 (3d bench) -2 (7d target) and a 30d window missing its target leg (abstains):
204
+ # net = -1, inside (-exit, +enter) -> the dead band emits nothing (also flat).
205
+ ("dead_band_emits_nothing",
206
+ all_bench, all_target, {benchmark: 3.0}, []),
207
+ # entry conditions met but the pair is already held -> held-exclusion suppresses entry.
208
+ ("held_exclusion_suppresses_entry",
209
+ all_bench, all_bench, all_bench, held),
210
+ # exit conditions met but the pair is FLAT -> nothing to close (no phantom exit).
211
+ ("flat_no_exit",
212
+ all_target, all_target, all_target, []),
213
+ )
214
+ return tuple(
215
+ {"name": name, "inputs": {"ret_3d": r3, "ret_7d": r7, "ret_30d": r30, "positions": pos},
216
+ "expect": call(r3, r7, r30, pos)}
217
+ for name, r3, r7, r30, pos in cases
218
+ )
219
+
220
+
221
+ def build(answers: Answers) -> GraphBuilder:
222
+ b = GraphBuilder()
223
+ u = answers.universe
224
+ iv = answers.intervals[0] if answers.intervals else "4h"
225
+
226
+ # relative_value.pairwise is a FIXED-PAIR shape: no discovery (that would prune the benchmark
227
+ # from a live-board narrowing) and EXACTLY two assets. Reject anything else LOUDLY rather than
228
+ # silently emit a broken universe (mirrors trend_momentum's discover guard).
229
+ if u.discover:
230
+ raise GenerationError(
231
+ "archetype 'relative_value' (pairwise member) is a FIXED-PAIR shape and cannot "
232
+ "discover a universe from the live board — discovery could prune the benchmark leg. "
233
+ "Set universe.whitelist to exactly [target, benchmark]. (A discovered cross-sectional "
234
+ "long-strongest/short-weakest variant is the designed cross_sectional follow-up member, "
235
+ "not yet built.)"
236
+ )
237
+ pair = list(u.whitelist)
238
+ if len(pair) != 2:
239
+ raise GenerationError(
240
+ "archetype 'relative_value' (pairwise member) needs EXACTLY two assets in "
241
+ f"universe.whitelist as [target, benchmark]; got {pair}. The FIRST is the traded "
242
+ "target, the SECOND is the reference benchmark (never traded)."
243
+ )
244
+ target, benchmark = pair[0], pair[1]
245
+
246
+ bars = _BARS_PER_DAY.get(iv)
247
+ if bars is None:
248
+ raise GenerationError(
249
+ f"archetype 'relative_value' cannot map interval {iv!r} to a bars-per-day count "
250
+ f"(supported: {sorted(_BARS_PER_DAY)}). Choose a standard candle interval so the "
251
+ f"lookback windows (3d/7d/30d) resolve to bar counts."
252
+ )
253
+
254
+ es = answers.exit_signals
255
+ enter_threshold = es.enter_threshold if es is not None else _DEFAULT_ENTER
256
+ exit_threshold = es.exit_threshold if es is not None else _DEFAULT_EXIT
257
+
258
+ # --- sources ---
259
+ b.add("account", "clearinghouse_state",
260
+ header=("sources: account equity for sizing. NO held-exclusion on the entry universe —",
261
+ "the fixed pair must ALWAYS include the benchmark leg (never prune it).",))
262
+ b.add("positions", "open_positions",
263
+ header=("open positions (held + direction): held-exclusion INSIDE the pure_fn and the",
264
+ "exit tail's REAL held-direction stamp. Kept even in the entry-only variant.",))
265
+
266
+ # --- candles over the FIXED pair (both legs every tick, never narrowed) ---
267
+ b.add("candles", "candles_fetch",
268
+ {"whitelist": [target, benchmark], "intervals": [iv]},
269
+ header=("candle reads over the FIXED pair [target, benchmark] — no discovery, no",
270
+ "held/dedup narrowing (narrowing could prune the benchmark and break the compare).",))
271
+
272
+ # --- N lookback returns (days -> bars at the chosen interval), each a per-asset feature_map ---
273
+ window_ids: list[str] = []
274
+ for i, (days, _weight) in enumerate(WINDOWS):
275
+ wid = f"ret_{days}d"
276
+ b.add(wid, "pct_change", {"lookback": days * bars, "interval": iv},
277
+ header=(f"lookback returns at {iv} ({days}d = {days * bars} x {iv}).",) if i == 0 else ())
278
+ window_ids.append(wid)
279
+
280
+ # --- the two-output signed-net-score comparison (THE EDGE slot; D2 kind 2) ---
281
+ weights = tuple(w for _d, w in WINDOWS)
282
+ b.add_pure_fn(
283
+ "score",
284
+ inputs=tuple(
285
+ {"name": wid, "type": "feature_map",
286
+ "doc": f"Per-asset {days}d ({days * bars} x {iv}) percent return."}
287
+ for wid, (days, _w) in zip(window_ids, WINDOWS)
288
+ ) + (
289
+ {"name": "positions", "type": "record_list",
290
+ "doc": "Open positions (from open_positions) — held-exclusion + exit direction."},
291
+ ),
292
+ outputs=(
293
+ {"name": "entry_scores", "type": "score_map",
294
+ "doc": "LONG the target when net >= enter_threshold and the target is FLAT."},
295
+ {"name": "exit_scores", "type": "score_map",
296
+ "doc": "CLOSE the target when net <= -exit_threshold and the target is HELD."},
297
+ ),
298
+ params=(
299
+ {"name": "benchmark", "type": "str", "default": benchmark,
300
+ "doc": "Reference leg (never traded); fetched only to compare against the target."},
301
+ {"name": "target", "type": "str", "default": target,
302
+ "doc": "The only tradeable name (bought when it lags the benchmark)."},
303
+ {"name": "w_3d", "type": "float", "default": weights[0], "doc": "Weight of the 3d window."},
304
+ {"name": "w_7d", "type": "float", "default": weights[1],
305
+ "doc": "Weight of the 7d window (longer = stronger)."},
306
+ {"name": "w_30d", "type": "float", "default": weights[2],
307
+ "doc": "Weight of the 30d window (longest = strongest)."},
308
+ {"name": "enter_threshold", "type": "float", "default": enter_threshold,
309
+ "doc": "Net score to ENTER LONG (top of the hysteresis dead band)."},
310
+ {"name": "exit_threshold", "type": "float", "default": exit_threshold,
311
+ "doc": "Net score (as -exit_threshold) to CLOSE (bottom of the dead band)."},
312
+ {"name": "signal_strength", "type": "float", "default": _SIGNAL_STRENGTH,
313
+ "doc": "Per-window vote unit multiplier."},
314
+ ),
315
+ fn=RELATIVE_VALUE_FN,
316
+ tests=_relative_value_tests(target, benchmark, weights, enter_threshold, exit_threshold),
317
+ header=("pure_fn signed-net-score comparison — THE EDGE SLOT (edit the body; see the EDGE",
318
+ "block above). TWO outputs: entry_scores (entry tail) + exit_scores (exit tail).",),
319
+ )
320
+ b.set_edge_anchor("score")
321
+
322
+ # --- entry tail: single-best (only the target is ever scored) -> signal_envelope ---
323
+ b.add("best", "emit_single_best", {"min_score": 0},
324
+ header=("entry tail: the target is the only scored name, so single-best emits it.",))
325
+ b.add("emit", "signal_envelope",
326
+ {"margin_pct": answers.risk.margin_pct, "leverage": answers.risk.leverage},
327
+ header=("entry emitter: build the scan() OPEN signal list.",))
328
+
329
+ # --- exit tail: only when a thesis (signal) exit was requested (answers.exit_signals) ---
330
+ if es is not None:
331
+ b.add("close_emit", "close_signal_envelope", {"signal_type": "thesis_exit"},
332
+ header=("exit emitter: reduce-only CLOSE list (no sizing/equity). The compiler splits",
333
+ "this into a second, exit-only scanner; the DSL preset stays as the safety net.",))
334
+
335
+ # --- edges ---
336
+ # candles over the FIXED pair fan out to the lookback returns
337
+ for i, wid in enumerate(window_ids):
338
+ b.connect("candles", "candles", wid, "candles",
339
+ section="candles over the fixed pair fan out to the lookback returns" if i == 0 else None)
340
+ # each return + the positions record wire into ONE declared pure_fn input (single writer)
341
+ for i, wid in enumerate(window_ids):
342
+ b.connect(wid, "pct", "score", wid,
343
+ section="each lookback return -> its pure_fn input port" if i == 0 else None)
344
+ b.connect("positions", "positions", "score", "positions")
345
+ # entry tail: entry_scores -> single-best -> emit (+ equity for sizing)
346
+ b.connect("score", "entry_scores", "best", "scores", section="entry tail: entry_scores -> rank -> emit")
347
+ b.connect("best", "selected", "emit", "selected")
348
+ b.connect("account", "equity", "emit", "equity")
349
+ # exit tail: exit_scores -> close emitter (+ positions for the real held-direction stamp)
350
+ if es is not None:
351
+ b.connect("score", "exit_scores", "close_emit", "selected",
352
+ section="exit tail: exit_scores -> close emitter (+ positions for direction)")
353
+ b.connect("positions", "positions", "close_emit", "positions")
354
+ return b
@@ -15,6 +15,23 @@ from ..answers import Answers
15
15
  from ..builder import GraphBuilder
16
16
  from . import _common
17
17
 
18
+ DESCRIBE = [
19
+ "# thesis_fund (archetype)",
20
+ "",
21
+ "Thesis shape: a FIXED long/short basket — each name's direction is fixed by its leg",
22
+ "(long -> LONG, short -> SHORT) — and a pure_fn scores how strongly the tape CONFIRMS that",
23
+ "side (dropping a name whose 4h structure opposes its leg). Confirming names are ranked and",
24
+ "the top (max_positions - held) fill the open slots.",
25
+ "",
26
+ "Emits: clearinghouse_state, ttl_dedup_state, instrument_list, held_asset_exclusion,",
27
+ "recent_signal_ttl_dedup, live_instrument_validation, candles_fetch, own_return_24h,",
28
+ "trend_structure x2, rsi, a `score` pure_fn (THE EDGE slot), rank_pool_cap_to_open_slots,",
29
+ "signal_envelope.",
30
+ "",
31
+ "Answers that matter: universe.long_basket / universe.short_basket (the two legs),",
32
+ "intervals, max_positions (the pool cap), ttl_seconds, risk.*.",
33
+ ]
34
+
18
35
  BANNER = [
19
36
  "thesis_fund strategy — machine-generated anchor (composer new).",
20
37
  "",
@@ -14,6 +14,22 @@ from ..answers import Answers
14
14
  from ..builder import GraphBuilder
15
15
  from . import _common
16
16
 
17
+ DESCRIBE = [
18
+ "# trend_momentum (archetype)",
19
+ "",
20
+ "Thesis shape: a FIXED universe scanned each tick; candles fan out to trend / momentum /",
21
+ "volume / acceleration indicators whose per-asset features merge into a hard-gates +",
22
+ "weighted-sum scorer, then single-best (or pool-cap) emission with TTL signal-dedup.",
23
+ "",
24
+ "Emits: clearinghouse_state, ttl_dedup_state, held_asset_exclusion, recent_signal_ttl_dedup,",
25
+ "candles_fetch, sm_lean, sma, recent_high_bars_ago, pct_change, volume_surge_ratio,",
26
+ "acceleration, hard_gates_all (THE EDGE slot), a ranker, signal_envelope.",
27
+ "",
28
+ "Answers that matter: universe.whitelist (REQUIRED — fixed-universe only; discover is",
29
+ "rejected), intervals, max_positions, ttl_seconds, risk.*, and scoring / indicators to",
30
+ "retune the scorer's hard-gate vs weighted split.",
31
+ ]
32
+
17
33
  BANNER = [
18
34
  "trend_momentum strategy — machine-generated anchor (composer new).",
19
35
  "",
@@ -52,13 +52,20 @@ class NewResult:
52
52
 
53
53
 
54
54
  def _bind_protection_default(answers: Answers) -> Answers:
55
- """PROTECTION BY DEFAULT (slice 9 policy): `composer new` BINDS ``protected_standard`` when
56
- the author left ``exit_preset`` unset and did not opt out via ``unmanaged``. Overriding
57
- (a chosen preset) or opting out (``unmanaged=true``) are both honoured untouched. Returns a
58
- (possibly) updated copy so the emitted graph metadata carries the bound value."""
55
+ """PROTECTION BY DEFAULT (slice 9 policy) + the exit-composition unset-vs-explicit refinement:
56
+ `composer new` RESOLVES an unset ``exit_preset`` at build time and binds it into the graph
57
+ metadata. Resolution when the author left ``exit_preset`` unset and did not opt out:
58
+
59
+ * no exit_signals -> ``protected_standard`` (today's default — BYTE-IDENTICAL to before).
60
+ * exit_signals set -> ``protected_wide`` (the DSL demotes to a WIDE safety net so it never
61
+ front-runs the thesis exit; the signal exit is primary, the DSL is the net).
62
+
63
+ Overriding (an explicit chosen preset) or opting out (``unmanaged=true``) are both honoured
64
+ untouched — an explicit value ALWAYS wins. Returns a (possibly) updated copy."""
59
65
  from .presets import DEFAULT_PRESET
60
66
  if not answers.unmanaged and answers.exit_preset is None:
61
- return answers.model_copy(update={"exit_preset": DEFAULT_PRESET})
67
+ resolved = "protected_wide" if answers.exit_signals is not None else DEFAULT_PRESET
68
+ return answers.model_copy(update={"exit_preset": resolved})
62
69
  return answers
63
70
 
64
71
 
@@ -146,6 +146,9 @@ RISK_OVERRIDE_KEYS = {
146
146
  "max_consecutive_losses": "max_consecutive_losses",
147
147
  "cooldown_seconds": "cooldown_seconds",
148
148
  "max_entries_per_day": "max_entries_per_day",
149
+ # post-close per-asset re-entry cooldown (schema floor 300). An explicit answers override wins;
150
+ # a signal-exit pair otherwise supplies exit_signals.reentry_cooldown_seconds (runtime_emit).
151
+ "per_asset_cooldown_seconds": "per_asset_cooldown_seconds",
149
152
  }
150
153
 
151
154