@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.
- package/dist/composer/authoring/__init__.py +2 -0
- package/dist/composer/authoring/answers.py +47 -3
- package/dist/composer/authoring/archetypes/__init__.py +19 -2
- package/dist/composer/authoring/archetypes/breakout.py +18 -0
- package/dist/composer/authoring/archetypes/classifier.py +16 -0
- package/dist/composer/authoring/archetypes/relative_value.py +354 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +17 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +16 -0
- package/dist/composer/authoring/generate.py +12 -5
- package/dist/composer/authoring/presets.py +3 -0
- package/dist/composer/authoring/runtime_emit.py +57 -1
- package/dist/composer/cli.py +30 -7
- package/dist/composer/compiler/__init__.py +25 -4
- package/dist/composer/compiler/compile.py +230 -12
- package/dist/composer/examples/conviction_scorer.graph.yaml +1 -1
- package/dist/composer/examples/pair_relative_strength.graph.yaml +263 -0
- package/dist/composer/examples/pre_listing_classifier.graph.yaml +1 -1
- package/dist/composer/generated/catalog.md +29 -1
- package/dist/composer/generated/describe.md +49 -0
- package/dist/composer/generated/graph.schema.json +50 -0
- package/dist/composer/primitives/emitters.py +55 -0
- package/dist/composer/primitives/sources.py +77 -0
- package/dist/composer/primitives/stateful.py +2 -2
- package/dist/composer/registry/__init__.py +2 -0
- package/dist/composer/registry/generate.py +250 -2
- package/dist/composer/registry/models.py +28 -0
- package/dist/composer/registry/nodes.py +74 -2
- package/dist/composer/templates/scan.py.j2 +2 -1
- package/dist/composer/validator/errors.py +4 -0
- package/dist/composer/verbs/check.py +31 -7
- package/dist/composer/verbs/deploy.py +26 -0
- package/dist/composer/verbs/docs.py +60 -4
- package/dist/composer/verbs/runtime_validate.py +152 -2
- package/package.json +2 -2
- 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"
|
|
158
|
-
#
|
|
159
|
-
#
|
|
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
|
-
|
|
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)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|