@senpi/runtime 3.0.9-dev.feat-strategy-compos.20260721003344 → 3.0.9-dev.feat-strategy-compos.20260722213012

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 (46) hide show
  1. package/dist/cli/composer-commands.d.ts.map +1 -1
  2. package/dist/cli/composer-commands.js +193 -41
  3. package/dist/cli/composer-commands.js.map +1 -1
  4. package/dist/cli/composer-live-read.d.ts +68 -0
  5. package/dist/cli/composer-live-read.d.ts.map +1 -0
  6. package/dist/cli/composer-live-read.js +126 -0
  7. package/dist/cli/composer-live-read.js.map +1 -0
  8. package/dist/cli/senpi-commands.d.ts.map +1 -1
  9. package/dist/cli/senpi-commands.js +8 -10
  10. package/dist/cli/senpi-commands.js.map +1 -1
  11. package/dist/cli/senpi-strategy-tool.d.ts +56 -0
  12. package/dist/cli/senpi-strategy-tool.d.ts.map +1 -0
  13. package/dist/cli/senpi-strategy-tool.js +158 -0
  14. package/dist/cli/senpi-strategy-tool.js.map +1 -0
  15. package/dist/composer/authoring/__init__.py +2 -0
  16. package/dist/composer/authoring/answers.py +52 -0
  17. package/dist/composer/authoring/archetypes/__init__.py +2 -0
  18. package/dist/composer/authoring/archetypes/contrarian_fade.py +493 -0
  19. package/dist/composer/authoring/archetypes/relative_value.py +280 -23
  20. package/dist/composer/cli.py +57 -6
  21. package/dist/composer/examples/contrarian_fade_crowd.graph.yaml +356 -0
  22. package/dist/composer/examples/rotation_cross_sectional.graph.yaml +389 -0
  23. package/dist/composer/generated/catalog.md +2 -1
  24. package/dist/composer/registry/generate.py +2 -1
  25. package/dist/composer/validator/errors.py +2 -0
  26. package/dist/composer/verbs/__init__.py +6 -0
  27. package/dist/composer/verbs/_box.py +96 -5
  28. package/dist/composer/verbs/_layout.py +37 -0
  29. package/dist/composer/verbs/_live.py +50 -6
  30. package/dist/composer/verbs/close.py +51 -6
  31. package/dist/composer/verbs/fund.py +144 -27
  32. package/dist/composer/verbs/install.py +25 -6
  33. package/dist/composer/verbs/portfolio.py +650 -0
  34. package/dist/composer/verbs/review.py +724 -0
  35. package/dist/composer/verbs/status.py +129 -2
  36. package/dist/composer/verbs/update.py +9 -3
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +22 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/runtime/composer-managed-write.d.ts +133 -0
  42. package/dist/runtime/composer-managed-write.d.ts.map +1 -0
  43. package/dist/runtime/composer-managed-write.js +369 -0
  44. package/dist/runtime/composer-managed-write.js.map +1 -0
  45. package/openclaw.plugin.json +5 -0
  46. package/package.json +2 -1
@@ -130,6 +130,44 @@ class CopyTrading(BaseModel):
130
130
  min_traders: int = 2 # per-coin cohort-thickness gate (agreement needs >= this)
131
131
 
132
132
 
133
+ class ContrarianFade(BaseModel):
134
+ """OPTIONAL contrarian-fade knobs — the ``contrarian_fade`` archetype family's ingredient
135
+ surface (slice JA; capability-regression audit §E3).
136
+
137
+ Absent (the default ``None`` on :class:`Answers`) => byte-identical output EVERYWHERE (the hard
138
+ regression constraint): the block is emitted into ``metadata.spec`` ONLY when set (same additive
139
+ pattern as ``copy_trading`` / ``exit_signals`` / ``group``), so every existing answers file
140
+ round-trips unchanged.
141
+
142
+ The MEMBER of the ``contrarian_fade`` family is INFERRED from the ingredients (no ``member``
143
+ field — the least-new-surface convention shared with relative_value's whitelist length,
144
+ copy_trading's trader_addresses, and trend_momentum's discover flag). The discriminant is
145
+ ``use_crowd`` — the genuine INGREDIENT difference between the two clusters the corpus splits into:
146
+
147
+ * ``use_crowd = True`` -> the **fade_crowd** member: fade a crowded/overextended move where the
148
+ crowd is read from smart-money lean (sm_lean) gated by the market-wide funding regime
149
+ (funding_regime_fetch). Mirrors dog / lemon / owl / egret (sm_crowding) + hyena
150
+ (risk_off_rotation, funding-regime master gate). SHORT an overbought rip the crowd is long;
151
+ LONG an oversold flush the crowd is short.
152
+ * ``use_crowd = False`` -> the **fade_price** member: fade pure PRICE overextension (RSI +
153
+ recent return), no positioning data. Mirrors salmon (mean_reversion) + bald-eagle
154
+ (xyz_overextended). SHORT overbought/overextended-up; LONG oversold/overextended-down.
155
+
156
+ ``overbought_rsi`` / ``oversold_rsi`` are the RSI extremes that mark exhaustion; ``min_extension_pct``
157
+ is the recent-return magnitude that qualifies a move as overextended (dog: 3%+ in ~4h);
158
+ ``crowd_threshold`` is the smart-money lean deviation from neutral (0.5) that counts as crowded
159
+ (dog: SM concentration ~15%+) and is INERT for fade_price. The universe is a FIXED whitelist OR a
160
+ discovered board (``universe.whitelist`` / ``universe.discover``) — the family reads whichever."""
161
+
162
+ model_config = ConfigDict(extra="forbid")
163
+
164
+ use_crowd: bool = True # member: True => fade_crowd (sm+funding), False => fade_price (RSI)
165
+ overbought_rsi: float = 70.0 # RSI at/above which an up-move is exhausted (fade SHORT)
166
+ oversold_rsi: float = 30.0 # RSI at/below which a down-move is exhausted (fade LONG)
167
+ min_extension_pct: float = 3.0 # |recent return %| that qualifies as overextended (dog 3%+/4h)
168
+ crowd_threshold: float = 0.15 # SM lean deviation from 0.5 that counts as crowded (fade_crowd)
169
+
170
+
133
171
  class Protection(BaseModel):
134
172
  """The MATERIALIZED exit-protection spec — the FULL resolved protection carried IN the graph
135
173
  (F1 "the graph IS the spec"). Before F1 the exit values were resolved from a named preset only
@@ -308,6 +346,10 @@ class Answers(BaseModel):
308
346
  # Absent (None) => byte-identical to today (no copy_trading key in the metadata block). Present
309
347
  # => the copy_trading archetype reads the followed-cohort ingredients from it. See :class:`CopyTrading`.
310
348
  copy_trading: Optional[CopyTrading] = None
349
+ # contrarian_fade — OPTIONAL ingredient surface for the contrarian_fade archetype family (slice JA).
350
+ # Absent (None) => byte-identical to today (no contrarian_fade key in the metadata block). Present
351
+ # => the contrarian_fade archetype reads the fade ingredients from it. See :class:`ContrarianFade`.
352
+ contrarian_fade: Optional[ContrarianFade] = None
311
353
 
312
354
  @field_validator("exit_preset")
313
355
  @classmethod
@@ -460,6 +502,16 @@ class Answers(BaseModel):
460
502
  "consistency": list(self.copy_trading.consistency),
461
503
  "min_traders": self.copy_trading.min_traders,
462
504
  }
505
+ # contrarian_fade: the fade ingredients, emitted ONLY when set (same F1 discipline) so answers
506
+ # without it round-trip byte-identically. Survives Answers(**block) as ContrarianFade.
507
+ if self.contrarian_fade is not None:
508
+ block["contrarian_fade"] = {
509
+ "use_crowd": self.contrarian_fade.use_crowd,
510
+ "overbought_rsi": self.contrarian_fade.overbought_rsi,
511
+ "oversold_rsi": self.contrarian_fade.oversold_rsi,
512
+ "min_extension_pct": self.contrarian_fade.min_extension_pct,
513
+ "crowd_threshold": self.contrarian_fade.crowd_threshold,
514
+ }
463
515
  return block
464
516
 
465
517
 
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
  from . import (
16
16
  breakout,
17
17
  classifier,
18
+ contrarian_fade,
18
19
  copy_trading,
19
20
  relative_value,
20
21
  skeleton,
@@ -29,6 +30,7 @@ ARCHETYPES = {
29
30
  "thesis_fund": thesis_fund,
30
31
  "relative_value": relative_value,
31
32
  "copy_trading": copy_trading,
33
+ "contrarian_fade": contrarian_fade,
32
34
  }
33
35
 
34
36
  SKELETON = skeleton
@@ -0,0 +1,493 @@
1
+ """contrarian_fade — the fade-the-overextension thesis shape (capability-regression audit §E3).
2
+
3
+ The routing incident this closes: "fade the crowd", "short the exhausted rip", "buy the oversold
4
+ flush", "mean-revert the majors" had NO archetype and NO routing row (audit §A.4 #3: 8-10 corpus
5
+ packages, all escape-hatch-only), so the agent found no route — even though every source node
6
+ already existed (sm_lean, funding_regime_fetch, rsi, pct_change). `contrarian_fade` is that missing
7
+ shape: it detects an OVEREXTENDED move (an RSI extreme confirmed by a recent price run) and enters
8
+ the OPPOSITE direction — SHORT an overbought rip, LONG an oversold flush — betting on mean reversion.
9
+
10
+ This is a FAMILY with two members (taxonomy: family = thesis shape, members = ingredient combos),
11
+ selected mechanically from the answers ingredients (the least-new-surface convention — no member
12
+ field; mirrors relative_value's whitelist length, copy_trading's trader_addresses, and
13
+ trend_momentum's discover flag). The discriminant is ``contrarian_fade.use_crowd`` — the genuine
14
+ INGREDIENT difference between the two clusters the 10 contrarian_fade corpus packages split into:
15
+
16
+ * ``fade_crowd`` (use_crowd=True, the DEFAULT) — fade a move the CROWD is piling into. The crowd
17
+ is read from smart-money lean (sm_lean) and gated by the market-wide funding regime
18
+ (funding_regime_fetch): SHORT an overbought rip ONLY when smart money is crowded-long and the
19
+ regime is not SHORT_CROWDED; LONG an oversold flush ONLY when crowded-short and not LONG_CROWDED.
20
+ Defaults MIRROR ``dog`` (Contrarian Pup: 3%+ move in ~4h + SM concentration ~15%+ -> fade) and
21
+ ``lemon`` / ``owl`` / ``egret`` (sm_crowding); the funding-regime master gate is ``hyena``'s
22
+ (risk_off_rotation). All source nodes exist.
23
+ * ``fade_price`` (use_crowd=False) — fade PURE price overextension (RSI + recent return), no
24
+ positioning data. Defaults MIRROR ``salmon`` (RSI Mean-Reversion) and ``bald-eagle``
25
+ (xyz_overextended macro fader). All source nodes exist.
26
+
27
+ Both members share the SCORING half's SHAPE — ONE two-output signed-fade pure_fn (D2 kind 2):
28
+
29
+ * entry_scores — ENTER the FADE (opposite the exhausted move) when the overextension test fires
30
+ (and, for fade_crowd, the crowd is aligned + the regime permits) AND the asset is NOT held.
31
+ * exit_scores — CLOSE a held fade when MEAN REVERSION IS COMPLETE (RSI has crossed back through
32
+ the 50 midline — the snap-back the fade was betting on) OR the THESIS IS INVALIDATED (the move
33
+ extended FURTHER against the fade — the crowd kept pushing; cut it).
34
+
35
+ Held-exclusion + the real held direction live INSIDE the fn (via an ``open_positions`` input) so the
36
+ entry and exit streams can never both fire — the relative_value / copy_trading complementarity
37
+ pattern. Exit topology is answer-driven (design orthogonal axis): when ``answers.exit_signals`` is
38
+ present the graph carries the exit tail (``close_signal_envelope`` wired to exit_scores + positions),
39
+ the compiler splits it into an entry scanner + a reduce-only exit scanner, and the resolved DSL
40
+ preset demotes to ``protected_wide`` (the WIDE safety net, never front-running the reversion exit).
41
+ When it is ABSENT the graph is ENTRY-ONLY (single module, DSL-managed exits); the fn still computes
42
+ exit_scores (unwired) and still applies held-exclusion (an entry firing into an already-held asset
43
+ would double the position, which the no-pyramiding default forbids). The DSL preset is ALWAYS
44
+ retained alongside a signal exit — the reversion signal is the thesis close, the DSL floor stays the
45
+ safety net for the case where the fade is simply wrong.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ from ..answers import Answers, ContrarianFade
51
+ from ..builder import GraphBuilder
52
+ from . import _common
53
+ from ._common import GenerationError
54
+
55
+ DESCRIBE = [
56
+ "# contrarian_fade (archetype family)",
57
+ "",
58
+ "Thesis shape: FADE an overextended move — 'short the exhausted rip', 'buy the oversold flush',",
59
+ "'fade the crowd', 'mean-revert the majors'. Detect an RSI extreme confirmed by a recent price",
60
+ "run and enter the OPPOSITE direction (SHORT an overbought rip, LONG an oversold flush), betting",
61
+ "on the snap back toward the mean. Universe is a FIXED whitelist OR a discovered board.",
62
+ "",
63
+ "Two MEMBERS, chosen from the ingredients you give (no member field — contrarian_fade.use_crowd):",
64
+ " * fade_crowd (use_crowd=true, DEFAULT) — fade what the CROWD is piling into: smart-money lean",
65
+ " (sm_lean) gated by the market-wide funding regime (funding_regime_fetch). SHORT an overbought",
66
+ " rip only when smart money is crowded-long and the regime is not SHORT_CROWDED (and mirror for",
67
+ " the LONG side). Mirrors dog / lemon / owl / egret (sm_crowding) + hyena (funding-regime gate).",
68
+ " * fade_price (use_crowd=false) — fade PURE price overextension (RSI + recent return), no",
69
+ " positioning data. Mirrors salmon (RSI mean-reversion) + bald-eagle (macro fader).",
70
+ "",
71
+ "The scoring half is ONE two-output signed-fade pure_fn (THE EDGE slot). ENTER the fade when the",
72
+ "overextension test fires and the asset is FLAT. EXIT a held fade when MEAN REVERSION IS COMPLETE",
73
+ "(RSI crossed back through the 50 midline) OR the THESIS IS INVALIDATED (the move extended further",
74
+ "against the fade). Held-exclusion + the real held direction live inside the fn.",
75
+ "",
76
+ "Exit topology (orthogonal axis): with answers.exit_signals the composer emits the two-scanner",
77
+ "pair (entry signal_envelope + reduce-only close_signal_envelope) and keeps a WIDE DSL preset as",
78
+ "the always-on safety net (the reversion signal is the thesis close, not protection). Omit",
79
+ "exit_signals for an ENTRY-ONLY variant (single module, DSL-managed exits).",
80
+ "",
81
+ "When to use vs breakout/trend_momentum (THE ROUTING TEST): if the thesis is 'the move is",
82
+ "EXHAUSTED, bet on it REVERSING' it is contrarian_fade (it inverts direction). If the thesis is",
83
+ "'the move CONTINUES' (ride the breakout/trend), it is breakout or trend_momentum.",
84
+ "",
85
+ "Emits: clearinghouse_state (equity for sizing), open_positions (held-exclusion + exit-direction",
86
+ "stamp), candles_fetch, rsi + pct_change (the overextension read), and for fade_crowd also sm_lean",
87
+ "+ funding_regime_fetch (the crowd read + market gate) and (when discovering) instrument_list +",
88
+ "universe_discover; a `score` pure_fn (THE EDGE slot, TWO outputs), a ranker (single-best or",
89
+ "pool-cap by max_positions) + signal_envelope (entry tail), and close_signal_envelope (exit tail",
90
+ "— only when exit_signals is set).",
91
+ "",
92
+ "Answers that matter: universe.whitelist OR universe.discover (+ dex / name_prefix /",
93
+ "max_leverage_cap / min_day_volume); contrarian_fade.{use_crowd, overbought_rsi, oversold_rsi,",
94
+ "min_extension_pct, crowd_threshold}; intervals; max_positions (single-best vs pool-cap);",
95
+ "exit_signals (present => the reversion exit tail); exit_preset; risk.*.",
96
+ ]
97
+
98
+ BANNER = [
99
+ "contrarian_fade strategy — machine-generated anchor (composer new).",
100
+ "",
101
+ "Fade an overextended move: detect an RSI extreme confirmed by a recent price run and enter the",
102
+ "OPPOSITE direction (SHORT an overbought rip, LONG an oversold flush), betting on mean reversion.",
103
+ "fade_crowd additionally requires the crowd (smart-money lean) to be piling in AND the market-wide",
104
+ "funding regime to permit the fade; fade_price fades pure price. A hysteresis-free reversion exit",
105
+ "closes when RSI crosses back through the midline or the move invalidates the fade, with",
106
+ "held-exclusion built in. Edit ONLY the pure_fn body (the EDGE slot) to move it toward your edge;",
107
+ "every intermediate state stays valid (`composer check`).",
108
+ ]
109
+
110
+ # Archetype shape constants (NOT answers fields).
111
+ # The RSI window and the recent-extension window are the family's fixed shape (like relative_value's
112
+ # WINDOWS). RSI 14 is the corpus-universal period; the extension is a short recent run (dog's "3% in
113
+ # ~4h" is roughly this window at 4h) expressed in bars at the chosen interval.
114
+ _RSI_PERIOD = 14
115
+ _EXTENSION_LOOKBACK = 6
116
+ # A held fade is INVALIDATED (the crowd kept pushing) when the move extends beyond this multiple of
117
+ # the entry extension threshold while the RSI extreme is unresolved. A shape constant, not an answer.
118
+ _INVALIDATION_FACTOR = 2.0
119
+ _SIGNAL_STRENGTH = 1.0
120
+
121
+ # The two-output signed-fade CROWD scorer (D2 kind 2). Generic over the bound thresholds (all passed
122
+ # as params); emitted verbatim AND executed in _contrarian_fade_tests to synthesize inline fixtures
123
+ # that pass by construction. Deterministic, math-only (purity-checked).
124
+ FADE_CROWD_FN = '''\
125
+ def score_fade_crowd(rsi, move, sm, regime, positions, overbought_rsi, oversold_rsi, min_extension_pct, crowd_threshold, invalidation_factor, signal_strength):
126
+ # TODO(edit): the CROWD-FADE edge (a WORKING DEFAULT; dog/lemon/egret/owl + hyena regime gate).
127
+ # For each asset, an OVEREXTENSION is an RSI extreme confirmed by a recent price run: overbought =
128
+ # rsi >= overbought_rsi AND move (recent % return) >= min_extension_pct; oversold is the mirror.
129
+ # Fade it ONLY when the CROWD is aligned WITH the exhausted move (smart-money tilt in [0,1], 0.5
130
+ # neutral: crowded-long = tilt >= 0.5+crowd_threshold) AND the market-wide funding regime PERMITS
131
+ # the fade (LONG_CROWDED -> only SHORT fades; SHORT_CROWDED -> only LONG fades; NEUTRAL/"" -> both).
132
+ # ENTER the OPPOSITE direction when FLAT. EXIT a held fade when MEAN REVERSION IS COMPLETE (RSI
133
+ # crossed back through the 50 midline) OR the THESIS IS INVALIDATED (the move extended further
134
+ # against the fade). Held-exclusion + the real held direction live here (via positions) so entry
135
+ # and exit can never both fire. A missing leg simply abstains (no vote), never raises. Encode YOUR
136
+ # edge by editing this body — it is purity-checked (no ctx/MCP/I/O, math only) and its inline tests
137
+ # run at validate time.
138
+ reg = (regime or "").upper()
139
+ allow_short = reg != "SHORT_CROWDED"
140
+ allow_long = reg != "LONG_CROWDED"
141
+ held = {}
142
+ for record in positions or []:
143
+ coin = str(record.get("coin", "")).upper()
144
+ if coin:
145
+ held[coin] = record.get("direction") or "LONG"
146
+ entry_scores = {}
147
+ exit_scores = {}
148
+ for asset in sorted(set(rsi) | set(move)):
149
+ r = rsi.get(asset)
150
+ mv = move.get(asset)
151
+ if r is None or mv is None:
152
+ continue
153
+ raw = sm.get(asset, 0.5)
154
+ tilt = raw.get("tilt", raw.get("lean", 0.5)) if isinstance(raw, dict) else raw
155
+ crowd_long = tilt >= 0.5 + crowd_threshold
156
+ crowd_short = tilt <= 0.5 - crowd_threshold
157
+ overbought = r >= overbought_rsi and mv >= min_extension_pct
158
+ oversold = r <= oversold_rsi and mv <= -min_extension_pct
159
+ held_direction = held.get(str(asset).upper())
160
+ if held_direction is None:
161
+ if overbought and crowd_long and allow_short:
162
+ score = signal_strength * (abs(r - overbought_rsi) + abs(mv))
163
+ entry_scores[asset] = {"score": round(score, 4), "direction": "SHORT",
164
+ "reasons": ["overbought_rip", "crowd_long"]}
165
+ elif oversold and crowd_short and allow_long:
166
+ score = signal_strength * (abs(oversold_rsi - r) + abs(mv))
167
+ entry_scores[asset] = {"score": round(score, 4), "direction": "LONG",
168
+ "reasons": ["oversold_flush", "crowd_short"]}
169
+ else:
170
+ if held_direction == "SHORT":
171
+ reverted = r <= 50.0
172
+ invalidated = r >= overbought_rsi and mv >= min_extension_pct * invalidation_factor
173
+ else:
174
+ reverted = r >= 50.0
175
+ invalidated = r <= oversold_rsi and mv <= -min_extension_pct * invalidation_factor
176
+ if reverted or invalidated:
177
+ exit_scores[asset] = {"score": round(abs(mv), 4), "direction": held_direction,
178
+ "reasons": ["reversion_complete" if reverted else "thesis_invalidated"]}
179
+ return entry_scores, exit_scores
180
+ '''
181
+
182
+ # The two-output signed-fade PRICE scorer — fade_price member (RSI + recent return, no positioning).
183
+ FADE_PRICE_FN = '''\
184
+ def score_fade_price(rsi, move, positions, overbought_rsi, oversold_rsi, min_extension_pct, invalidation_factor, signal_strength):
185
+ # TODO(edit): the PRICE-FADE edge (a WORKING DEFAULT; salmon RSI mean-reversion + bald-eagle).
186
+ # For each asset an OVEREXTENSION is an RSI extreme confirmed by a recent price run: overbought =
187
+ # rsi >= overbought_rsi AND move (recent % return) >= min_extension_pct; oversold is the mirror.
188
+ # Fade it (SHORT an overbought rip, LONG an oversold flush) when FLAT — no positioning data, pure
189
+ # price. EXIT a held fade when MEAN REVERSION IS COMPLETE (RSI crossed back through the 50 midline)
190
+ # OR the THESIS IS INVALIDATED (the move extended further against the fade). Held-exclusion + the
191
+ # real held direction live here (via positions). A missing leg abstains, never raises. Encode YOUR
192
+ # edge by editing this body — it is purity-checked (no ctx/MCP/I/O, math only) and its inline tests
193
+ # run at validate time.
194
+ held = {}
195
+ for record in positions or []:
196
+ coin = str(record.get("coin", "")).upper()
197
+ if coin:
198
+ held[coin] = record.get("direction") or "LONG"
199
+ entry_scores = {}
200
+ exit_scores = {}
201
+ for asset in sorted(set(rsi) | set(move)):
202
+ r = rsi.get(asset)
203
+ mv = move.get(asset)
204
+ if r is None or mv is None:
205
+ continue
206
+ overbought = r >= overbought_rsi and mv >= min_extension_pct
207
+ oversold = r <= oversold_rsi and mv <= -min_extension_pct
208
+ held_direction = held.get(str(asset).upper())
209
+ if held_direction is None:
210
+ if overbought:
211
+ score = signal_strength * (abs(r - overbought_rsi) + abs(mv))
212
+ entry_scores[asset] = {"score": round(score, 4), "direction": "SHORT",
213
+ "reasons": ["overbought_rip"]}
214
+ elif oversold:
215
+ score = signal_strength * (abs(oversold_rsi - r) + abs(mv))
216
+ entry_scores[asset] = {"score": round(score, 4), "direction": "LONG",
217
+ "reasons": ["oversold_flush"]}
218
+ else:
219
+ if held_direction == "SHORT":
220
+ reverted = r <= 50.0
221
+ invalidated = r >= overbought_rsi and mv >= min_extension_pct * invalidation_factor
222
+ else:
223
+ reverted = r >= 50.0
224
+ invalidated = r <= oversold_rsi and mv <= -min_extension_pct * invalidation_factor
225
+ if reverted or invalidated:
226
+ exit_scores[asset] = {"score": round(abs(mv), 4), "direction": held_direction,
227
+ "reasons": ["reversion_complete" if reverted else "thesis_invalidated"]}
228
+ return entry_scores, exit_scores
229
+ '''
230
+
231
+
232
+ def _exec_fn(fn_src: str):
233
+ ns: dict = {}
234
+ exec(fn_src, ns) # noqa: S102 - trusted, in-repo reference body used only for test-gen
235
+ for name, obj in ns.items():
236
+ if callable(obj) and name != "__builtins__":
237
+ return obj
238
+ raise RuntimeError("no function found in reference body")
239
+
240
+
241
+ def _crowd_tests(cf: ContrarianFade) -> tuple[dict, ...]:
242
+ """Six inline fixtures whose expected values are computed from the default CROWD body itself with
243
+ the BOUND thresholds, so they pass by construction (emitted body == executed body). Semantic cases:
244
+ crowd-long overbought entry (SHORT), crowd-short oversold entry (LONG), regime veto (LONG_CROWDED
245
+ blocks a LONG fade), crowd-not-aligned no-entry, reversion-complete exit (held), thesis-invalidated
246
+ exit (held)."""
247
+ fn = _exec_fn(FADE_CROWD_FN)
248
+ ob, os_, ext, ct = cf.overbought_rsi, cf.oversold_rsi, cf.min_extension_pct, cf.crowd_threshold
249
+ inv = _INVALIDATION_FACTOR
250
+ held_short = [{"coin": "BTC", "direction": "SHORT", "szi": -1.0,
251
+ "entry_px": 100.0, "unrealized_pnl_pct": 1.0, "margin_used": 10.0}]
252
+
253
+ def call(rsi, move, sm, regime, pos):
254
+ e, x = fn(rsi, move, sm, regime, pos, ob, os_, ext, ct, inv, _SIGNAL_STRENGTH)
255
+ return {"entry_scores": e, "exit_scores": x}
256
+
257
+ hot = ob + 5.0 # clearly overbought
258
+ cold = os_ - 5.0 # clearly oversold
259
+ big_up = ext + 2.0 # a real up-move
260
+ big_dn = -(ext + 2.0) # a real down-move
261
+ crowd_long = 0.5 + ct + 0.1
262
+ crowd_short = 0.5 - ct - 0.1
263
+ cases = (
264
+ # overbought rip + crowd long + neutral regime + FLAT -> ENTER SHORT (fade the crowd).
265
+ ("crowd_long_overbought_fade_short",
266
+ {"BTC": hot}, {"BTC": big_up}, {"BTC": crowd_long}, "NEUTRAL", []),
267
+ # oversold flush + crowd short + neutral regime + FLAT -> ENTER LONG (fade the crowd).
268
+ ("crowd_short_oversold_fade_long",
269
+ {"ETH": cold}, {"ETH": big_dn}, {"ETH": crowd_short}, "NEUTRAL", []),
270
+ # oversold + crowd short but regime is LONG_CROWDED (only SHORT fades allowed) -> VETO.
271
+ ("regime_vetoes_long_fade",
272
+ {"ETH": cold}, {"ETH": big_dn}, {"ETH": crowd_short}, "LONG_CROWDED", []),
273
+ # overbought rip but the crowd is NOT long (tilt neutral) -> no entry (crowding is the gate).
274
+ ("crowd_not_aligned_no_entry",
275
+ {"BTC": hot}, {"BTC": big_up}, {"BTC": 0.5}, "NEUTRAL", []),
276
+ # HELD SHORT + RSI back below the midline -> EXIT (mean reversion complete).
277
+ ("reversion_complete_exit_when_held",
278
+ {"BTC": 45.0}, {"BTC": 0.0}, {"BTC": 0.5}, "NEUTRAL", held_short),
279
+ # HELD SHORT + RSI still overbought AND move extended 2x further -> EXIT (thesis invalidated).
280
+ ("thesis_invalidated_exit_when_held",
281
+ {"BTC": hot}, {"BTC": ext * inv + 1.0}, {"BTC": crowd_long}, "NEUTRAL", held_short),
282
+ )
283
+ return tuple(
284
+ {"name": name,
285
+ "inputs": {"rsi": rsi, "move": move, "sm": sm, "regime": regime, "positions": pos},
286
+ "expect": call(rsi, move, sm, regime, pos)}
287
+ for name, rsi, move, sm, regime, pos in cases
288
+ )
289
+
290
+
291
+ def _price_tests(cf: ContrarianFade) -> tuple[dict, ...]:
292
+ """Five inline fixtures computed from the default PRICE body itself with the BOUND thresholds:
293
+ overbought entry (SHORT), oversold entry (LONG), in-band no-entry, reversion-complete exit (held),
294
+ held-exclusion suppresses a fresh entry."""
295
+ fn = _exec_fn(FADE_PRICE_FN)
296
+ ob, os_, ext = cf.overbought_rsi, cf.oversold_rsi, cf.min_extension_pct
297
+ inv = _INVALIDATION_FACTOR
298
+ held_short = [{"coin": "SOL", "direction": "SHORT", "szi": -1.0,
299
+ "entry_px": 100.0, "unrealized_pnl_pct": 1.0, "margin_used": 10.0}]
300
+
301
+ def call(rsi, move, pos):
302
+ e, x = fn(rsi, move, pos, ob, os_, ext, inv, _SIGNAL_STRENGTH)
303
+ return {"entry_scores": e, "exit_scores": x}
304
+
305
+ hot = ob + 5.0
306
+ cold = os_ - 5.0
307
+ big_up = ext + 2.0
308
+ big_dn = -(ext + 2.0)
309
+ cases = (
310
+ # overbought rip + FLAT -> ENTER SHORT (pure price fade).
311
+ ("overbought_fade_short", {"SOL": hot}, {"SOL": big_up}, []),
312
+ # oversold flush + FLAT -> ENTER LONG.
313
+ ("oversold_fade_long", {"AVAX": cold}, {"AVAX": big_dn}, []),
314
+ # RSI mid-range + small move -> nothing overextended.
315
+ ("in_band_no_entry", {"SOL": 55.0}, {"SOL": 0.5}, []),
316
+ # HELD SHORT + RSI back below the midline -> EXIT (reversion complete).
317
+ ("reversion_complete_exit_when_held", {"SOL": 45.0}, {"SOL": 0.0}, held_short),
318
+ # overbought rip but the asset is already held -> held-exclusion suppresses a fresh entry.
319
+ ("held_exclusion_suppresses_entry", {"SOL": hot}, {"SOL": big_up}, held_short),
320
+ )
321
+ return tuple(
322
+ {"name": name, "inputs": {"rsi": rsi, "move": move, "positions": pos},
323
+ "expect": call(rsi, move, pos)}
324
+ for name, rsi, move, pos in cases
325
+ )
326
+
327
+
328
+ def build(answers: Answers) -> GraphBuilder:
329
+ b = GraphBuilder()
330
+ cf = answers.contrarian_fade or ContrarianFade()
331
+ iv = answers.intervals[0] if answers.intervals else "4h"
332
+ u = answers.universe
333
+
334
+ # The universe is a FIXED whitelist OR a discovered board — reject a mis-shaped universe LOUDLY
335
+ # (mirrors the other archetypes' guards) so a bad answer never silently emits a broken graph.
336
+ if u.long_basket or u.short_basket:
337
+ raise GenerationError(
338
+ "archetype 'contrarian_fade' derives each asset's fade direction from its own "
339
+ "overextension (not a fixed leg); it does not take long_basket/short_basket. Use "
340
+ "universe.whitelist (a fixed basket) or universe.discover."
341
+ )
342
+ if not u.discover and not u.whitelist:
343
+ raise GenerationError(
344
+ "archetype 'contrarian_fade' needs a universe to fade: set universe.whitelist to a fixed "
345
+ "basket (dog/egret/lemon shape) OR universe.discover=true to fade a discovered board "
346
+ "(salmon/owl/pangolin shape)."
347
+ )
348
+
349
+ member = "fade_crowd" if cf.use_crowd else "fade_price"
350
+
351
+ es = answers.exit_signals
352
+
353
+ # --- sources shared by both members ---
354
+ b.add("account", "clearinghouse_state",
355
+ header=("sources: account equity for sizing. NO held-exclusion at the universe level —",
356
+ "it lives in the pure_fn (fade only when flat, exit only when held).",))
357
+ b.add("positions", "open_positions",
358
+ header=("open positions (held + direction): in-fn held-exclusion + the exit tail's REAL",
359
+ "held-direction stamp. Kept even in the entry-only variant (no-pyramiding).",))
360
+
361
+ # --- universe: fixed whitelist OR discovered board ---
362
+ if u.discover:
363
+ b.add("board", "instrument_list", {"dex": u.dex},
364
+ header=("dynamic universe discovery: live board -> leverage/volume/prefix signature.",))
365
+ b.add("universe", "universe_discover",
366
+ {"name_prefix": u.name_prefix, "max_leverage_cap": u.max_leverage_cap,
367
+ "min_day_volume": u.min_day_volume})
368
+ b.add("candles", "candles_fetch", {"intervals": list(answers.intervals)},
369
+ header=("candle reads over the discovered universe.",))
370
+ else:
371
+ b.add("candles", "candles_fetch",
372
+ {"whitelist": list(u.whitelist), "intervals": list(answers.intervals)},
373
+ header=("candle reads over the fixed fade basket.",))
374
+
375
+ # --- the overextension read: RSI extreme + a recent price run ---
376
+ b.add("rsi", "rsi", {"period": _RSI_PERIOD, "interval": iv},
377
+ header=("overextension read: RSI extreme + a recent price run.",))
378
+ b.add("move", "pct_change", {"lookback": _EXTENSION_LOOKBACK, "interval": iv})
379
+
380
+ # --- fade_crowd only: the crowd read (smart-money lean) + the market-wide funding-regime gate ---
381
+ if member == "fade_crowd":
382
+ b.add("lean", "sm_lean", {"agree_threshold": 0.6},
383
+ param_comments={"agree_threshold": "TODO(edit): smart-money agreement band (fade corpus 0.55-0.60)."},
384
+ slots=("agree_threshold",),
385
+ header=("crowd read: smart-money lean (fade only WITH the crowd) + the funding regime gate.",))
386
+ b.add("regime", "funding_regime_fetch")
387
+
388
+ # --- the two-output signed-fade scorer (THE EDGE slot; D2 kind 2) ---
389
+ common_params = (
390
+ {"name": "overbought_rsi", "type": "float", "default": cf.overbought_rsi,
391
+ "doc": "RSI at/above which an up-move is exhausted (fade SHORT)."},
392
+ {"name": "oversold_rsi", "type": "float", "default": cf.oversold_rsi,
393
+ "doc": "RSI at/below which a down-move is exhausted (fade LONG)."},
394
+ {"name": "min_extension_pct", "type": "float", "default": cf.min_extension_pct,
395
+ "doc": "|recent return %| that qualifies a move as overextended (dog 3%+/~4h)."},
396
+ )
397
+ tail_params = (
398
+ {"name": "invalidation_factor", "type": "float", "default": _INVALIDATION_FACTOR,
399
+ "doc": "A held fade is INVALIDATED when the move extends past this multiple of "
400
+ "min_extension_pct while the RSI extreme is unresolved (the crowd kept pushing)."},
401
+ {"name": "signal_strength", "type": "float", "default": _SIGNAL_STRENGTH,
402
+ "doc": "Per-signal score unit multiplier."},
403
+ )
404
+ outputs = (
405
+ {"name": "entry_scores", "type": "score_map",
406
+ "doc": "ENTER the FADE (opposite the exhausted move) when overextended and FLAT."},
407
+ {"name": "exit_scores", "type": "score_map",
408
+ "doc": "CLOSE a held fade when RSI crosses back through the 50 midline OR the move invalidates it."},
409
+ )
410
+
411
+ if member == "fade_crowd":
412
+ b.add_pure_fn(
413
+ "score",
414
+ inputs=(
415
+ {"name": "rsi", "type": "feature_map", "doc": "Per-asset RSI in [0,100]."},
416
+ {"name": "move", "type": "feature_map", "doc": "Per-asset recent percent return (extension)."},
417
+ {"name": "sm", "type": "feature_map", "doc": "Per-asset smart-money tilt in [0,1] (0.5 neutral)."},
418
+ {"name": "regime", "type": "label",
419
+ "doc": "Market-wide funding regime (LONG_CROWDED|SHORT_CROWDED|NEUTRAL): the fade master gate."},
420
+ {"name": "positions", "type": "record_list",
421
+ "doc": "Open positions (from open_positions) — held-exclusion + exit direction."},
422
+ ),
423
+ outputs=outputs,
424
+ params=common_params + (
425
+ {"name": "crowd_threshold", "type": "float", "default": cf.crowd_threshold,
426
+ "doc": "Smart-money lean deviation from 0.5 that counts as crowded (dog ~15%+)."},
427
+ ) + tail_params,
428
+ fn=FADE_CROWD_FN,
429
+ tests=_crowd_tests(cf),
430
+ header=("pure_fn signed-fade scorer (crowd) — THE EDGE SLOT (edit the body; see the EDGE",
431
+ "block above). TWO outputs: entry_scores (entry tail) + exit_scores (exit tail).",),
432
+ title="crowd fade: RSI/price overextension the smart-money crowd is piling into",
433
+ )
434
+ else:
435
+ b.add_pure_fn(
436
+ "score",
437
+ inputs=(
438
+ {"name": "rsi", "type": "feature_map", "doc": "Per-asset RSI in [0,100]."},
439
+ {"name": "move", "type": "feature_map", "doc": "Per-asset recent percent return (extension)."},
440
+ {"name": "positions", "type": "record_list",
441
+ "doc": "Open positions (from open_positions) — held-exclusion + exit direction."},
442
+ ),
443
+ outputs=outputs,
444
+ params=common_params + tail_params,
445
+ fn=FADE_PRICE_FN,
446
+ tests=_price_tests(cf),
447
+ header=("pure_fn signed-fade scorer (price) — THE EDGE SLOT (edit the body; see the EDGE",
448
+ "block above). TWO outputs: entry_scores (entry tail) + exit_scores (exit tail).",),
449
+ title="price fade: RSI + recent-return overextension, mean-reversion entry",
450
+ )
451
+ b.set_edge_anchor("score")
452
+
453
+ # --- entry tail: ranker (single-best vs pool-cap by cardinality) -> emitter ---
454
+ _common.add_ranker(
455
+ b, ranker_id="best", scorer_id="score", scorer_port="entry_scores",
456
+ account_id="account", max_positions=answers.max_positions,
457
+ )
458
+ b.add("emit", "signal_envelope",
459
+ {"margin_pct": answers.risk.margin_pct, "leverage": answers.risk.leverage},
460
+ header=("entry emitter: build the scan() OPEN signal list.",))
461
+
462
+ # --- exit tail: only when a thesis (reversion) exit was requested (answers.exit_signals) ---
463
+ if es is not None:
464
+ b.add("close_emit", "close_signal_envelope", {"signal_type": "thesis_exit"},
465
+ header=("exit emitter: reduce-only CLOSE list (no sizing/equity). The compiler splits",
466
+ "this into a second, exit-only scanner; the DSL preset stays as the safety net.",))
467
+
468
+ # --- edges ---
469
+ # universe -> candles (+ sm_lean for the discovered case)
470
+ if u.discover:
471
+ b.connect("board", "instruments", "universe", "instruments", section="dynamic universe discovery")
472
+ b.connect("universe", "universe", "candles", "assets", section="discovered universe -> candle reads")
473
+ if member == "fade_crowd":
474
+ b.connect("universe", "universe", "lean", "assets")
475
+ # candles fan out to the overextension indicators
476
+ b.connect("candles", "candles", "rsi", "candles", section="candles fan out to the overextension read")
477
+ b.connect("candles", "candles", "move", "candles")
478
+ # per-asset features + positions (+ crowd read) wire into the pure_fn (single writer each)
479
+ b.connect("rsi", "rsi", "score", "rsi", section="overextension + positions -> the fade pure_fn")
480
+ b.connect("move", "pct", "score", "move")
481
+ if member == "fade_crowd":
482
+ b.connect("lean", "lean", "score", "sm")
483
+ b.connect("regime", "regime", "score", "regime")
484
+ b.connect("positions", "positions", "score", "positions")
485
+ # entry tail: entry_scores -> rank (wired inside add_ranker) -> emit (+ equity for sizing)
486
+ b.connect("best", "selected", "emit", "selected", section="entry tail: entry_scores -> rank -> emit")
487
+ b.connect("account", "equity", "emit", "equity")
488
+ # exit tail: exit_scores -> close emitter (+ positions for the real held-direction stamp)
489
+ if es is not None:
490
+ b.connect("score", "exit_scores", "close_emit", "selected",
491
+ section="exit tail: exit_scores -> close emitter (+ positions for direction)")
492
+ b.connect("positions", "positions", "close_emit", "positions")
493
+ return b