@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.
- package/dist/cli/composer-commands.d.ts.map +1 -1
- package/dist/cli/composer-commands.js +193 -41
- package/dist/cli/composer-commands.js.map +1 -1
- package/dist/cli/composer-live-read.d.ts +68 -0
- package/dist/cli/composer-live-read.d.ts.map +1 -0
- package/dist/cli/composer-live-read.js +126 -0
- package/dist/cli/composer-live-read.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +8 -10
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/senpi-strategy-tool.d.ts +56 -0
- package/dist/cli/senpi-strategy-tool.d.ts.map +1 -0
- package/dist/cli/senpi-strategy-tool.js +158 -0
- package/dist/cli/senpi-strategy-tool.js.map +1 -0
- package/dist/composer/authoring/__init__.py +2 -0
- package/dist/composer/authoring/answers.py +52 -0
- package/dist/composer/authoring/archetypes/__init__.py +2 -0
- package/dist/composer/authoring/archetypes/contrarian_fade.py +493 -0
- package/dist/composer/authoring/archetypes/relative_value.py +280 -23
- package/dist/composer/cli.py +57 -6
- package/dist/composer/examples/contrarian_fade_crowd.graph.yaml +356 -0
- package/dist/composer/examples/rotation_cross_sectional.graph.yaml +389 -0
- package/dist/composer/generated/catalog.md +2 -1
- package/dist/composer/registry/generate.py +2 -1
- package/dist/composer/validator/errors.py +2 -0
- package/dist/composer/verbs/__init__.py +6 -0
- package/dist/composer/verbs/_box.py +96 -5
- package/dist/composer/verbs/_layout.py +37 -0
- package/dist/composer/verbs/_live.py +50 -6
- package/dist/composer/verbs/close.py +51 -6
- package/dist/composer/verbs/fund.py +144 -27
- package/dist/composer/verbs/install.py +25 -6
- package/dist/composer/verbs/portfolio.py +650 -0
- package/dist/composer/verbs/review.py +724 -0
- package/dist/composer/verbs/status.py +129 -2
- package/dist/composer/verbs/update.py +9 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/composer-managed-write.d.ts +133 -0
- package/dist/runtime/composer-managed-write.d.ts.map +1 -0
- package/dist/runtime/composer-managed-write.js +369 -0
- package/dist/runtime/composer-managed-write.js.map +1 -0
- package/openclaw.plugin.json +5 -0
- 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
|