@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
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# relative_value strategy (pairwise member) — machine-generated anchor (composer new).
|
|
2
|
+
#
|
|
3
|
+
# A FIXED pair [target, benchmark] compared cross-asset: N lookback returns (3d/7d/30d) feed
|
|
4
|
+
# ONE two-output signed-net-score pure_fn. The benchmark leg is a REFERENCE only — always
|
|
5
|
+
# fetched, never traded, never pruned (narrowing could drop it and break the comparison). The
|
|
6
|
+
# fn LONGs the target when the benchmark runs ahead (target is cheap) and CLOSEs it when the
|
|
7
|
+
# pair flips, with a hysteresis dead band and held-exclusion built in. Edit ONLY the pure_fn
|
|
8
|
+
# body (the EDGE slot) to move this toward your edge; every intermediate state stays valid
|
|
9
|
+
# (`composer check`).
|
|
10
|
+
|
|
11
|
+
version: 1
|
|
12
|
+
|
|
13
|
+
metadata:
|
|
14
|
+
archetype: relative_value
|
|
15
|
+
strategy: rotate_the_majors
|
|
16
|
+
generated:
|
|
17
|
+
by: "composer new"
|
|
18
|
+
content_hash: "1c3bbbe3999aa96dea3af04909293680fa9d46658245a525db6ee9bb7366f5cc"
|
|
19
|
+
structure_hash: "9efd6b0e577007a9db320ac103d5663a6f2d805794eab68726ff62757e193adc"
|
|
20
|
+
spec:
|
|
21
|
+
version: 2
|
|
22
|
+
archetype: relative_value
|
|
23
|
+
strategy: rotate_the_majors
|
|
24
|
+
thesis: "rotate into relative strength: rank the majors by weighted 3d/7d/30d return vs the basket mean, go long the strongest and fade the weakest within my slots; close a leg when its relative value conve..."
|
|
25
|
+
universe:
|
|
26
|
+
whitelist: ["BTC", "ETH", "SOL", "HYPE", "AVAX", "LINK"]
|
|
27
|
+
long_basket: []
|
|
28
|
+
short_basket: []
|
|
29
|
+
discover: false
|
|
30
|
+
dex: ""
|
|
31
|
+
name_prefix: ""
|
|
32
|
+
max_leverage_cap: 5.0
|
|
33
|
+
min_day_volume: 0.0
|
|
34
|
+
intervals: ["4h"]
|
|
35
|
+
max_positions: 3
|
|
36
|
+
dedup_seconds: 240
|
|
37
|
+
exit_preset: protected_wide
|
|
38
|
+
unmanaged: false
|
|
39
|
+
protection:
|
|
40
|
+
seeded_from: protected_wide
|
|
41
|
+
interval_seconds: 60
|
|
42
|
+
dsl_preset:
|
|
43
|
+
hard_timeout:
|
|
44
|
+
enabled: true
|
|
45
|
+
interval_in_minutes: 20160
|
|
46
|
+
weak_peak_cut:
|
|
47
|
+
enabled: false
|
|
48
|
+
interval_in_minutes: 240
|
|
49
|
+
dead_weight_cut:
|
|
50
|
+
enabled: false
|
|
51
|
+
interval_in_minutes: 480
|
|
52
|
+
phase1:
|
|
53
|
+
enabled: true
|
|
54
|
+
max_loss_pct: 25.0
|
|
55
|
+
retrace_threshold: 15
|
|
56
|
+
consecutive_breaches_required: 2
|
|
57
|
+
phase2:
|
|
58
|
+
enabled: true
|
|
59
|
+
tiers:
|
|
60
|
+
- trigger_pct: 15
|
|
61
|
+
lock_hw_pct: 0
|
|
62
|
+
- trigger_pct: 30
|
|
63
|
+
lock_hw_pct: 30
|
|
64
|
+
- trigger_pct: 60
|
|
65
|
+
lock_hw_pct: 55
|
|
66
|
+
- trigger_pct: 120
|
|
67
|
+
lock_hw_pct: 72
|
|
68
|
+
- trigger_pct: 250
|
|
69
|
+
lock_hw_pct: 85
|
|
70
|
+
guard_rails:
|
|
71
|
+
daily_loss_limit_pct: 15
|
|
72
|
+
max_consecutive_losses: 4
|
|
73
|
+
cooldown_seconds: 3600
|
|
74
|
+
drawdown_halt_pct: 25
|
|
75
|
+
drawdown_reset_on_day_rollover: false
|
|
76
|
+
per_asset_cooldown_seconds: 1800
|
|
77
|
+
risk:
|
|
78
|
+
margin_pct: 5.0
|
|
79
|
+
leverage: 3.0
|
|
80
|
+
drawdown_halt_pct: null
|
|
81
|
+
max_consecutive_losses: null
|
|
82
|
+
cooldown_seconds: null
|
|
83
|
+
max_entries_per_day: null
|
|
84
|
+
edge: "rotate into relative strength: rank the majors by weighted 3d/7d/30d return vs the basket mean, go long the strongest and fade the weakest within my slots; close a leg when its relative value converges back to the pack."
|
|
85
|
+
requires: []
|
|
86
|
+
exit_signals:
|
|
87
|
+
interval_seconds: 300
|
|
88
|
+
enter_threshold: 3.0
|
|
89
|
+
exit_threshold: 2.0
|
|
90
|
+
reentry_cooldown_seconds: 1800
|
|
91
|
+
# EDGE (verbatim, machine never interprets): "rotate into relative strength: rank the majors by weighted 3d/7d/30d return vs the basket mean, go long the strongest and fade the weakest within my slots; close a leg when its relative value converges back to the pack."
|
|
92
|
+
|
|
93
|
+
nodes:
|
|
94
|
+
# sources: account equity for sizing. NO held-exclusion on the ranked universe —
|
|
95
|
+
# held-exclusion lives in the pure_fn (enter only when flat, exit only when held).
|
|
96
|
+
- id: account
|
|
97
|
+
node: clearinghouse_state
|
|
98
|
+
# open positions (held + direction): in-fn held-exclusion + the exit tail's REAL
|
|
99
|
+
# held-direction stamp. Kept even in the entry-only variant (no-pyramiding).
|
|
100
|
+
- id: positions
|
|
101
|
+
node: open_positions
|
|
102
|
+
# candle reads over the fixed basket (ranked cross-sectionally).
|
|
103
|
+
- id: candles
|
|
104
|
+
node: candles_fetch
|
|
105
|
+
with:
|
|
106
|
+
whitelist: ["BTC", "ETH", "SOL", "HYPE", "AVAX", "LINK"]
|
|
107
|
+
intervals: ["4h"]
|
|
108
|
+
# lookback returns at 4h (3d = 18 x 4h).
|
|
109
|
+
- id: ret_3d
|
|
110
|
+
node: pct_change
|
|
111
|
+
with:
|
|
112
|
+
lookback: 18
|
|
113
|
+
interval: "4h"
|
|
114
|
+
- id: ret_7d
|
|
115
|
+
node: pct_change
|
|
116
|
+
with:
|
|
117
|
+
lookback: 42
|
|
118
|
+
interval: "4h"
|
|
119
|
+
- id: ret_30d
|
|
120
|
+
node: pct_change
|
|
121
|
+
with:
|
|
122
|
+
lookback: 180
|
|
123
|
+
interval: "4h"
|
|
124
|
+
# ---------------------------------------------------------------
|
|
125
|
+
# EDGE (verbatim — the machine did NOT interpret this; encode it
|
|
126
|
+
# by editing the TODO slots on this scoring node):
|
|
127
|
+
# rotate into relative strength: rank the majors by weighted 3d/7d/30d return vs the basket mean, go long the strongest and fade the weakest within my slots; close a leg when its relative value converges back to the pack.
|
|
128
|
+
# ---------------------------------------------------------------
|
|
129
|
+
# pure_fn cross-sectional relative-strength scorer — THE EDGE SLOT (edit the body; see
|
|
130
|
+
# the EDGE block above). TWO outputs: entry_scores (long strong / short weak) + exit_scores.
|
|
131
|
+
- id: score
|
|
132
|
+
kind: pure_fn
|
|
133
|
+
title: "cross-sectional relative strength: long the strongest, fade the weakest"
|
|
134
|
+
inputs:
|
|
135
|
+
- { name: ret_3d, type: feature_map, doc: "Per-asset 3d (18 x 4h) percent return." }
|
|
136
|
+
- { name: ret_7d, type: feature_map, doc: "Per-asset 7d (42 x 4h) percent return." }
|
|
137
|
+
- { name: ret_30d, type: feature_map, doc: "Per-asset 30d (180 x 4h) percent return." }
|
|
138
|
+
- { name: positions, type: record_list, doc: "Open positions (from open_positions) — held-exclusion + exit direction." }
|
|
139
|
+
outputs:
|
|
140
|
+
- { name: entry_scores, type: score_map, doc: "LONG the strongest (RS >= enter_threshold) / SHORT the weakest (RS <= -enter_threshold), FLAT." }
|
|
141
|
+
- { name: exit_scores, type: score_map, doc: "CLOSE a held name whose |RS| converged below exit_threshold OR whose RS flipped sign." }
|
|
142
|
+
params:
|
|
143
|
+
- { name: enter_threshold, type: float, default: 3.0, doc: "Relative strength to ENTER (top of the dead band); |RS| in return-percent units." }
|
|
144
|
+
- { name: exit_threshold, type: float, default: 2.0, doc: "Convergence band; a held name whose |RS| falls below it CLOSES (relative value realized)." }
|
|
145
|
+
- { name: w_3d, type: float, default: 1.0, doc: "Weight of the 3d window." }
|
|
146
|
+
- { name: w_7d, type: float, default: 2.0, doc: "Weight of the 7d window (longer = stronger)." }
|
|
147
|
+
- { name: w_30d, type: float, default: 3.0, doc: "Weight of the 30d window (longest = strongest)." }
|
|
148
|
+
- { name: signal_strength, type: float, default: 1.0, doc: "Per-signal score unit multiplier." }
|
|
149
|
+
fn: |
|
|
150
|
+
def score_cross_sectional(ret_3d, ret_7d, ret_30d, positions, enter_threshold, exit_threshold, w_3d, w_7d, w_30d, signal_strength):
|
|
151
|
+
# TODO(edit): the CROSS-SECTIONAL relative-strength edge (a WORKING DEFAULT; the `rotation`
|
|
152
|
+
# cluster — sailfish/lion/gibbon). For each asset the COMPOSITE momentum is the WEIGHTED AVERAGE
|
|
153
|
+
# of its 3d/7d/30d returns (a longer window is a more durable read, so it carries more weight).
|
|
154
|
+
# RELATIVE STRENGTH is composite minus the CROSS-SECTIONAL MEAN composite across the universe.
|
|
155
|
+
# ENTER when FLAT: LONG the strongest (RS >= enter_threshold), SHORT (fade) the weakest
|
|
156
|
+
# (RS <= -enter_threshold); between the bands is the dead band (emit nothing). EXIT a HELD name
|
|
157
|
+
# when its relative value has CONVERGED (|RS| <= exit_threshold — the spread the trade was betting
|
|
158
|
+
# on has closed) OR its RS has FLIPPED sign (it rotated to the other tail). Both tails score by
|
|
159
|
+
# |RS| so the fixed ranker caps to the top-N by magnitude. Held-exclusion + the real held direction
|
|
160
|
+
# live here (via positions) so entry and exit never both fire. A window missing a leg abstains; an
|
|
161
|
+
# empty universe returns nothing, never raises. Encode YOUR edge by editing this body — it is
|
|
162
|
+
# purity-checked (no ctx/MCP/I/O, math only) and its inline tests run at validate time.
|
|
163
|
+
composite = {}
|
|
164
|
+
for asset in sorted(set(ret_3d) | set(ret_7d) | set(ret_30d)):
|
|
165
|
+
acc = 0.0
|
|
166
|
+
wsum = 0.0
|
|
167
|
+
for series, weight in ((ret_3d, w_3d), (ret_7d, w_7d), (ret_30d, w_30d)):
|
|
168
|
+
value = series.get(asset)
|
|
169
|
+
if value is None:
|
|
170
|
+
continue
|
|
171
|
+
acc = acc + value * weight
|
|
172
|
+
wsum = wsum + weight
|
|
173
|
+
if wsum <= 0.0:
|
|
174
|
+
continue
|
|
175
|
+
composite[asset] = acc / wsum
|
|
176
|
+
if not composite:
|
|
177
|
+
return {}, {}
|
|
178
|
+
mean = sum(composite.values()) / len(composite)
|
|
179
|
+
held = {}
|
|
180
|
+
for record in positions or []:
|
|
181
|
+
coin = str(record.get("coin", "")).upper()
|
|
182
|
+
if coin:
|
|
183
|
+
held[coin] = record.get("direction") or "LONG"
|
|
184
|
+
entry_scores = {}
|
|
185
|
+
exit_scores = {}
|
|
186
|
+
for asset in sorted(composite):
|
|
187
|
+
rs = composite[asset] - mean
|
|
188
|
+
held_direction = held.get(str(asset).upper())
|
|
189
|
+
if held_direction is None:
|
|
190
|
+
if rs >= enter_threshold:
|
|
191
|
+
entry_scores[asset] = {"score": round(signal_strength * rs, 4), "direction": "LONG",
|
|
192
|
+
"reasons": ["relative_strength"]}
|
|
193
|
+
elif rs <= -enter_threshold:
|
|
194
|
+
entry_scores[asset] = {"score": round(signal_strength * abs(rs), 4), "direction": "SHORT",
|
|
195
|
+
"reasons": ["relative_weakness"]}
|
|
196
|
+
else:
|
|
197
|
+
converged = abs(rs) <= exit_threshold
|
|
198
|
+
flipped = (held_direction == "LONG" and rs < 0.0) or (held_direction == "SHORT" and rs > 0.0)
|
|
199
|
+
if converged or flipped:
|
|
200
|
+
exit_scores[asset] = {"score": round(abs(rs), 4), "direction": held_direction,
|
|
201
|
+
"reasons": ["converged" if converged else "rank_flip"]}
|
|
202
|
+
return entry_scores, exit_scores
|
|
203
|
+
tests:
|
|
204
|
+
- name: long_strongest_entry
|
|
205
|
+
inputs:
|
|
206
|
+
ret_3d:
|
|
207
|
+
AAA: 8.0
|
|
208
|
+
BBB: 0.0
|
|
209
|
+
CCC: -8.0
|
|
210
|
+
ret_7d:
|
|
211
|
+
AAA: 8.0
|
|
212
|
+
BBB: 0.0
|
|
213
|
+
CCC: -8.0
|
|
214
|
+
ret_30d:
|
|
215
|
+
AAA: 8.0
|
|
216
|
+
BBB: 0.0
|
|
217
|
+
CCC: -8.0
|
|
218
|
+
positions: []
|
|
219
|
+
expect:
|
|
220
|
+
entry_scores:
|
|
221
|
+
AAA:
|
|
222
|
+
score: 8.0
|
|
223
|
+
direction: LONG
|
|
224
|
+
reasons: ["relative_strength"]
|
|
225
|
+
CCC:
|
|
226
|
+
score: 8.0
|
|
227
|
+
direction: SHORT
|
|
228
|
+
reasons: ["relative_weakness"]
|
|
229
|
+
exit_scores: {}
|
|
230
|
+
- name: short_weakest_entry
|
|
231
|
+
inputs:
|
|
232
|
+
ret_3d:
|
|
233
|
+
AAA: 8.0
|
|
234
|
+
BBB: 0.0
|
|
235
|
+
CCC: -8.0
|
|
236
|
+
ret_7d:
|
|
237
|
+
AAA: 8.0
|
|
238
|
+
BBB: 0.0
|
|
239
|
+
CCC: -8.0
|
|
240
|
+
ret_30d:
|
|
241
|
+
AAA: 8.0
|
|
242
|
+
BBB: 0.0
|
|
243
|
+
CCC: -8.0
|
|
244
|
+
positions: []
|
|
245
|
+
expect:
|
|
246
|
+
entry_scores:
|
|
247
|
+
AAA:
|
|
248
|
+
score: 8.0
|
|
249
|
+
direction: LONG
|
|
250
|
+
reasons: ["relative_strength"]
|
|
251
|
+
CCC:
|
|
252
|
+
score: 8.0
|
|
253
|
+
direction: SHORT
|
|
254
|
+
reasons: ["relative_weakness"]
|
|
255
|
+
exit_scores: {}
|
|
256
|
+
- name: dead_band_emits_nothing
|
|
257
|
+
inputs:
|
|
258
|
+
ret_3d:
|
|
259
|
+
AAA: 1.0
|
|
260
|
+
BBB: 0.0
|
|
261
|
+
CCC: -1.0
|
|
262
|
+
ret_7d:
|
|
263
|
+
AAA: 1.0
|
|
264
|
+
BBB: 0.0
|
|
265
|
+
CCC: -1.0
|
|
266
|
+
ret_30d:
|
|
267
|
+
AAA: 1.0
|
|
268
|
+
BBB: 0.0
|
|
269
|
+
CCC: -1.0
|
|
270
|
+
positions: []
|
|
271
|
+
expect:
|
|
272
|
+
entry_scores: {}
|
|
273
|
+
exit_scores: {}
|
|
274
|
+
- name: converged_exit_when_held
|
|
275
|
+
inputs:
|
|
276
|
+
ret_3d:
|
|
277
|
+
AAA: 1.0
|
|
278
|
+
BBB: 0.0
|
|
279
|
+
CCC: -1.0
|
|
280
|
+
ret_7d:
|
|
281
|
+
AAA: 1.0
|
|
282
|
+
BBB: 0.0
|
|
283
|
+
CCC: -1.0
|
|
284
|
+
ret_30d:
|
|
285
|
+
AAA: 1.0
|
|
286
|
+
BBB: 0.0
|
|
287
|
+
CCC: -1.0
|
|
288
|
+
positions:
|
|
289
|
+
- coin: AAA
|
|
290
|
+
direction: LONG
|
|
291
|
+
szi: 1.0
|
|
292
|
+
entry_px: 100.0
|
|
293
|
+
unrealized_pnl_pct: 2.0
|
|
294
|
+
margin_used: 10.0
|
|
295
|
+
expect:
|
|
296
|
+
entry_scores: {}
|
|
297
|
+
exit_scores:
|
|
298
|
+
AAA:
|
|
299
|
+
score: 1.0
|
|
300
|
+
direction: LONG
|
|
301
|
+
reasons: ["converged"]
|
|
302
|
+
- name: rank_flip_exit_when_held
|
|
303
|
+
inputs:
|
|
304
|
+
ret_3d:
|
|
305
|
+
AAA: -8.0
|
|
306
|
+
BBB: 0.0
|
|
307
|
+
CCC: 8.0
|
|
308
|
+
ret_7d:
|
|
309
|
+
AAA: -8.0
|
|
310
|
+
BBB: 0.0
|
|
311
|
+
CCC: 8.0
|
|
312
|
+
ret_30d:
|
|
313
|
+
AAA: -8.0
|
|
314
|
+
BBB: 0.0
|
|
315
|
+
CCC: 8.0
|
|
316
|
+
positions:
|
|
317
|
+
- coin: AAA
|
|
318
|
+
direction: LONG
|
|
319
|
+
szi: 1.0
|
|
320
|
+
entry_px: 100.0
|
|
321
|
+
unrealized_pnl_pct: 2.0
|
|
322
|
+
margin_used: 10.0
|
|
323
|
+
expect:
|
|
324
|
+
entry_scores:
|
|
325
|
+
CCC:
|
|
326
|
+
score: 8.0
|
|
327
|
+
direction: LONG
|
|
328
|
+
reasons: ["relative_strength"]
|
|
329
|
+
exit_scores:
|
|
330
|
+
AAA:
|
|
331
|
+
score: 8.0
|
|
332
|
+
direction: LONG
|
|
333
|
+
reasons: ["rank_flip"]
|
|
334
|
+
- name: flat_no_exit
|
|
335
|
+
inputs:
|
|
336
|
+
ret_3d:
|
|
337
|
+
AAA: 1.0
|
|
338
|
+
BBB: 0.0
|
|
339
|
+
CCC: -1.0
|
|
340
|
+
ret_7d:
|
|
341
|
+
AAA: 1.0
|
|
342
|
+
BBB: 0.0
|
|
343
|
+
CCC: -1.0
|
|
344
|
+
ret_30d:
|
|
345
|
+
AAA: 1.0
|
|
346
|
+
BBB: 0.0
|
|
347
|
+
CCC: -1.0
|
|
348
|
+
positions: []
|
|
349
|
+
expect:
|
|
350
|
+
entry_scores: {}
|
|
351
|
+
exit_scores: {}
|
|
352
|
+
# ranker: rank the pool, fill up to 3 open slots (cardinality = 3).
|
|
353
|
+
- id: best
|
|
354
|
+
node: rank_pool_cap_to_open_slots
|
|
355
|
+
with:
|
|
356
|
+
max_slots: 3
|
|
357
|
+
min_score: 0
|
|
358
|
+
# entry emitter: build the scan() OPEN signal list.
|
|
359
|
+
- id: emit
|
|
360
|
+
node: signal_envelope
|
|
361
|
+
with:
|
|
362
|
+
margin_pct: 5.0
|
|
363
|
+
leverage: 3.0
|
|
364
|
+
# exit emitter: reduce-only CLOSE list (no sizing/equity). The compiler splits
|
|
365
|
+
# this into a second, exit-only scanner; the DSL preset stays as the safety net.
|
|
366
|
+
- id: close_emit
|
|
367
|
+
node: close_signal_envelope
|
|
368
|
+
with:
|
|
369
|
+
signal_type: "thesis_exit"
|
|
370
|
+
|
|
371
|
+
edges:
|
|
372
|
+
# score -> rank -> emit
|
|
373
|
+
- { from: score.entry_scores, to: best.scores }
|
|
374
|
+
- { from: account.held, to: best.held }
|
|
375
|
+
# candles fan out to the lookback returns
|
|
376
|
+
- { from: candles.candles, to: ret_3d.candles }
|
|
377
|
+
- { from: candles.candles, to: ret_7d.candles }
|
|
378
|
+
- { from: candles.candles, to: ret_30d.candles }
|
|
379
|
+
# each lookback return -> its pure_fn input port
|
|
380
|
+
- { from: ret_3d.pct, to: score.ret_3d }
|
|
381
|
+
- { from: ret_7d.pct, to: score.ret_7d }
|
|
382
|
+
- { from: ret_30d.pct, to: score.ret_30d }
|
|
383
|
+
- { from: positions.positions, to: score.positions }
|
|
384
|
+
# entry tail: entry_scores -> rank -> emit
|
|
385
|
+
- { from: best.selected, to: emit.selected }
|
|
386
|
+
- { from: account.equity, to: emit.equity }
|
|
387
|
+
# exit tail: exit_scores -> close emitter (+ positions for direction)
|
|
388
|
+
- { from: score.exit_scores, to: close_emit.selected }
|
|
389
|
+
- { from: positions.positions, to: close_emit.positions }
|
|
@@ -123,5 +123,6 @@ Edge data types (closed vocab). `composer describe <type>` gives producers/consu
|
|
|
123
123
|
- **breakout** — directional range-break hard gate, fixed basket OR discovered universe.
|
|
124
124
|
- **classifier** — a pure_fn direction classifier on a discovered/fixed universe.
|
|
125
125
|
- **thesis_fund** — fixed long/short basket, direction fixed per leg, pure_fn confirmation.
|
|
126
|
-
- **relative_value** — pairwise
|
|
126
|
+
- **relative_value** — pairwise [target, benchmark] OR cross_sectional rank-the-universe (long strong / fade weak).
|
|
127
127
|
- **copy_trading** — follow a cohort (named wallets OR the leaderboard); copy their per-coin conviction.
|
|
128
|
+
- **contrarian_fade** — fade an overextended move (RSI + price); crowd-gated (sm+funding) OR pure price.
|
|
@@ -397,8 +397,9 @@ def _catalog_archetypes() -> list[str]:
|
|
|
397
397
|
"- **breakout** — directional range-break hard gate, fixed basket OR discovered universe.",
|
|
398
398
|
"- **classifier** — a pure_fn direction classifier on a discovered/fixed universe.",
|
|
399
399
|
"- **thesis_fund** — fixed long/short basket, direction fixed per leg, pure_fn confirmation.",
|
|
400
|
-
"- **relative_value** — pairwise
|
|
400
|
+
"- **relative_value** — pairwise [target, benchmark] OR cross_sectional rank-the-universe (long strong / fade weak).",
|
|
401
401
|
"- **copy_trading** — follow a cohort (named wallets OR the leaderboard); copy their per-coin conviction.",
|
|
402
|
+
"- **contrarian_fade** — fade an overextended move (RSI + price); crowd-gated (sm+funding) OR pure price.",
|
|
402
403
|
]
|
|
403
404
|
|
|
404
405
|
|
|
@@ -139,6 +139,8 @@ ERROR_CODES: dict[str, str] = {
|
|
|
139
139
|
"CMP258": "`composer new --from-spec` refused — the target graph has a generation marker but no readable `metadata.spec` block to rebuild the Answers from (a pre-F1 / legacy `answers:` graph, or a spec that was stripped/retyped); regenerate from the interview answers with `composer new <strategy> --archetype <A> …`.",
|
|
140
140
|
# ---- H2 status live-read: the running runtime's scanner/protection/risk state is unreachable ----
|
|
141
141
|
"CMP259": "The live runtime state (scanner cadence, per-position DSL protection, risk gates) could not be read from the gateway — it is unreachable, the runtime is not actually running, or it returned an unrecognized response; `composer status` still reports the lifecycle chain it CAN derive and marks the live section unavailable (never a silent partial). Check `openclaw senpi runtime list` / `openclaw gateway run`, then re-run.",
|
|
142
|
+
# ---- J3 manual strategy: `composer fund --manual` create-path collision ----
|
|
143
|
+
"CMP262": "`composer fund --manual` refused — the target name already exists as a MANAGED strategy (a graph.yaml record, a staged/ or releases/ tree, or a managed wallet.json). A manual strategy is a wallet with NO graph/runtime and cannot be created over a managed one; pick a different name, or manage the existing strategy as the graph strategy it already is.",
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
|
|
@@ -29,6 +29,8 @@ from .group import (
|
|
|
29
29
|
run_group_status,
|
|
30
30
|
)
|
|
31
31
|
from .install import CreateResult, InstallResult, install_target, run_install
|
|
32
|
+
from .portfolio import PortfolioResult, run_portfolio_status
|
|
33
|
+
from .review import ReviewResult, run_review
|
|
32
34
|
from .simulate import SimulateResult, run_simulate
|
|
33
35
|
from .smoke import SmokeError, run_smoke
|
|
34
36
|
from .status import StatusResult, run_status
|
|
@@ -54,6 +56,10 @@ __all__ = [
|
|
|
54
56
|
"run_group_status",
|
|
55
57
|
"GroupCloseResult",
|
|
56
58
|
"run_group_close",
|
|
59
|
+
"PortfolioResult",
|
|
60
|
+
"run_portfolio_status",
|
|
61
|
+
"ReviewResult",
|
|
62
|
+
"run_review",
|
|
57
63
|
"StatusResult",
|
|
58
64
|
"run_status",
|
|
59
65
|
"UpdateResult",
|
|
@@ -26,6 +26,7 @@ like ``install._default_create``.
|
|
|
26
26
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
|
+
import json
|
|
29
30
|
import os
|
|
30
31
|
import re
|
|
31
32
|
import subprocess
|
|
@@ -184,13 +185,86 @@ def _run_cli(args: list[str], timeout: int = 60) -> CliResult:
|
|
|
184
185
|
return CliResult(-1, "", f"timed out after {timeout}s: {' '.join(argv)}")
|
|
185
186
|
|
|
186
187
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
# --------------------------------------------------------------------------- rim-served live doc
|
|
189
|
+
|
|
190
|
+
# J9: eliminate the openclaw cold boot on the HOT READ paths. When the composer verbs run VIA THE
|
|
191
|
+
# RIM (the exec CLI or the senpi_strategy gateway tool), the spawning TS process ALREADY has gateway
|
|
192
|
+
# RPC access, so it fetches the system-state envelope + runtime-list rows IN-PROCESS (reusing the
|
|
193
|
+
# same gateway RPCs `senpi state --json` / `senpi runtime list` use) and hands them to this Python
|
|
194
|
+
# child as a temp JSON document via ``SENPI_LIVE_FILE``. The default read seams then serve from that
|
|
195
|
+
# ONE document instead of each shelling a FRESH ``openclaw`` (a full CLI + plugins + OTel boot that
|
|
196
|
+
# only RPCs back to the process family the rim started in — a pure loop). The subprocess shell-out
|
|
197
|
+
# remains as the FALLBACK for a direct/offline invocation with no live-file handed down.
|
|
198
|
+
#
|
|
199
|
+
# The document is a versioned, extensible envelope: ``{version, system_state?, runtime_list?, ...}``
|
|
200
|
+
# — J8's events read channel adds an ``events`` section without reshaping. An env var (not a CLI
|
|
201
|
+
# flag) is the handoff: the read seams are wired as DEFAULT values deep inside the verb call trees
|
|
202
|
+
# (portfolio → run_status → _attach_live → state_fn; close → _close_one → list_fn), so threading a
|
|
203
|
+
# ``--live-file`` arg would mean plumbing it through every verb's run_* signature down to the seam
|
|
204
|
+
# defaults; an env var is read at the point of use with ZERO signature changes, exactly like the
|
|
205
|
+
# existing OPENCLAW_BIN / SENPI_STATE_DIR / SENPI_LOG_LEVEL reads in ``_run_cli``.
|
|
206
|
+
|
|
207
|
+
def load_live_document() -> Optional[dict]:
|
|
208
|
+
"""The rim-served live document named by ``SENPI_LIVE_FILE`` (a JSON
|
|
209
|
+
``{version, system_state?, runtime_list?}`` envelope the TS rim fetched IN-PROCESS), or None
|
|
210
|
+
when no live-file was handed down (a direct/offline invocation → the seams shell openclaw)."""
|
|
211
|
+
path = os.environ.get("SENPI_LIVE_FILE", "").strip()
|
|
212
|
+
if not path:
|
|
213
|
+
return None
|
|
214
|
+
try:
|
|
215
|
+
with open(path, encoding="utf-8") as fh:
|
|
216
|
+
doc = json.load(fh)
|
|
217
|
+
except (OSError, ValueError):
|
|
218
|
+
return None
|
|
219
|
+
return doc if isinstance(doc, dict) else None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _runtimes_from_live(section: Any) -> list[Runtime]:
|
|
223
|
+
"""Map the live document's ``runtime_list`` section (gateway-native ``senpi.listRuntimes`` rows
|
|
224
|
+
``{id, source, status, wallet}``) into the composer ``Runtime`` shape ``parse_runtime_list``
|
|
225
|
+
yields (``{name, wallet, source, status}``). A missing/errored section degrades to ``[]`` —
|
|
226
|
+
the SAME empty-on-error behavior the subprocess fallback has (the registry simply isn't
|
|
227
|
+
visible), so a rim-side fetch failure never masquerades as a populated list."""
|
|
228
|
+
if not isinstance(section, dict) or not section.get("ok"):
|
|
229
|
+
return []
|
|
230
|
+
out: list[Runtime] = []
|
|
231
|
+
for r in section.get("rows") or []:
|
|
232
|
+
if not isinstance(r, dict):
|
|
233
|
+
continue
|
|
234
|
+
out.append({
|
|
235
|
+
"name": r.get("id") or r.get("name"),
|
|
236
|
+
"wallet": r.get("wallet"),
|
|
237
|
+
"source": r.get("source"),
|
|
238
|
+
"status": r.get("status"),
|
|
239
|
+
})
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _shell_list_runtimes() -> list[Runtime]:
|
|
244
|
+
"""Shell the FROZEN ``openclaw senpi runtime list`` consumer → parsed rows (empty on error)."""
|
|
190
245
|
res = _run_cli(["openclaw", "senpi", "runtime", "list"])
|
|
191
246
|
return parse_runtime_list(res.stdout) if res.ok else []
|
|
192
247
|
|
|
193
248
|
|
|
249
|
+
def default_list_runtimes() -> list[Runtime]:
|
|
250
|
+
"""Production ListFn seam: a rim-served live document (``SENPI_LIVE_FILE``) supplies the runtime
|
|
251
|
+
rows WITHOUT an openclaw boot (J9); the subprocess shell-out is the FALLBACK for a direct/offline
|
|
252
|
+
invocation with no live-file. Empty on error either way (the runtime registry simply isn't
|
|
253
|
+
visible from a box without openclaw)."""
|
|
254
|
+
doc = load_live_document()
|
|
255
|
+
if doc is not None and "runtime_list" in doc:
|
|
256
|
+
return _runtimes_from_live(doc.get("runtime_list"))
|
|
257
|
+
return _shell_list_runtimes()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def list_runtimes_fresh() -> list[Runtime]:
|
|
261
|
+
"""A FRESH runtime-list read that BYPASSES any rim-served live document (J9). The post-delete
|
|
262
|
+
teardown CONFIRM in close/update MUST NOT read a pre-run snapshot — the document was captured
|
|
263
|
+
BEFORE this Python process deleted the runtime, so serving the confirm from it would falsely see
|
|
264
|
+
the just-deleted runtime as still live (a spurious CMP221/CMP245). Always shells the consumer."""
|
|
265
|
+
return _shell_list_runtimes()
|
|
266
|
+
|
|
267
|
+
|
|
194
268
|
def default_delete_runtime(runtime_id: str, wallet: str) -> CliResult:
|
|
195
269
|
"""Production DeleteFn seam: shell out to the FROZEN ``openclaw senpi runtime delete``
|
|
196
270
|
consumer (senpi-commands.ts:110). ``--address`` scopes the delete to the bound wallet."""
|
|
@@ -353,6 +427,22 @@ def _pos_num(v) -> Optional[float]:
|
|
|
353
427
|
return None
|
|
354
428
|
|
|
355
429
|
|
|
430
|
+
def unwrap_positions_envelope(doc: Any) -> Any:
|
|
431
|
+
"""Descend into a ``{success, data}`` MCP envelope BEFORE shape-parsing. The gateway wraps
|
|
432
|
+
``strategy_get_clearinghouse_state`` as ``{success: true, data: {main, xyz}, meta: {...}}`` and
|
|
433
|
+
``default_mcp_call`` returns that whole document, so the per-DEX buckets live one level down under
|
|
434
|
+
``.data``. This mirrors the TS call path (client.ts::fetchClearingHouseStateOnce unwraps via
|
|
435
|
+
``unwrapEnvelope`` at client.ts:791 BEFORE clearingHouseStateLooksParsable /
|
|
436
|
+
openPositionsFromClearingHouseState run) — those TS helpers never had the bug because their caller
|
|
437
|
+
unwraps first. Non-envelope shapes (an already-unwrapped flat list or top-level ``{main, xyz}``)
|
|
438
|
+
have no boolean ``success`` key and are returned unchanged, so existing shapes keep parsing."""
|
|
439
|
+
if not isinstance(doc, dict):
|
|
440
|
+
return doc
|
|
441
|
+
if not isinstance(doc.get("success"), bool) or "data" not in doc:
|
|
442
|
+
return doc
|
|
443
|
+
return doc.get("data")
|
|
444
|
+
|
|
445
|
+
|
|
356
446
|
def clearinghouse_looks_parsable(state: dict) -> bool:
|
|
357
447
|
"""True if ``state`` is a recognizable strategy_get_clearinghouse_state response — either a
|
|
358
448
|
flat ``positions``/``positionsList`` list, or per-DEX buckets carrying ``assetPositions`` /
|
|
@@ -432,6 +522,7 @@ def default_read_positions(wallet: str, mcp_fn: McpFn = default_mcp_call) -> Pos
|
|
|
432
522
|
return PositionsResult(False, [], reason=f"MCP error reading positions ({e})")
|
|
433
523
|
except Exception as e: # noqa: BLE001 — a surprise transport fault must not crash the verb
|
|
434
524
|
return PositionsResult(False, [], reason=f"could not read positions ({e})")
|
|
435
|
-
|
|
525
|
+
state = unwrap_positions_envelope(raw)
|
|
526
|
+
if not isinstance(state, dict) or not clearinghouse_looks_parsable(state):
|
|
436
527
|
return PositionsResult(False, [], reason="clearinghouse response shape not recognized")
|
|
437
|
-
return PositionsResult(True, parse_open_positions(
|
|
528
|
+
return PositionsResult(True, parse_open_positions(state))
|
|
@@ -41,6 +41,17 @@ REBASELINE_DIR = "rebaseline"
|
|
|
41
41
|
# legacy layout (read-side fallback only) -----------------------------------------------------
|
|
42
42
|
LEGACY_STRATEGIES_DIR = "strategies"
|
|
43
43
|
|
|
44
|
+
# strategy KIND (slice J3) --------------------------------------------------------------------
|
|
45
|
+
# A strategy dir is either MANAGED (a graph + runtime under composer's lifecycle) or MANUAL (a
|
|
46
|
+
# funded wallet with NO graph/runtime ever — direct MCP position edits are the sanctioned path).
|
|
47
|
+
# The kind is persisted as an OPTIONAL ``"kind": "manual"`` marker INSIDE the canonical
|
|
48
|
+
# ``wallet.json`` — the least-new-surface place: wallet.json already lives under this module's
|
|
49
|
+
# path authority (``canonical_wallet_json``) and is the one artifact a manual strategy owns. Its
|
|
50
|
+
# ABSENCE means MANAGED (back-compat: every pre-J3 wallet.json, and every managed strategy, has no
|
|
51
|
+
# marker and is unchanged byte-for-byte). No sibling metadata file, no new path.
|
|
52
|
+
KIND_MANAGED = "managed"
|
|
53
|
+
KIND_MANUAL = "manual"
|
|
54
|
+
|
|
44
55
|
# runtime-owned (frozen) ----------------------------------------------------------------------
|
|
45
56
|
ARCHIVES_DIR = "archives"
|
|
46
57
|
|
|
@@ -141,6 +152,32 @@ def write_wallet_json(base_state_dir: str, strategy: str) -> Path:
|
|
|
141
152
|
return canonical_wallet_json(base_state_dir, strategy)
|
|
142
153
|
|
|
143
154
|
|
|
155
|
+
def wallet_kind(rec: Optional[dict]) -> str:
|
|
156
|
+
"""The strategy KIND encoded in a wallet.json record (J3). Returns :data:`KIND_MANUAL` only for
|
|
157
|
+
an explicit ``"kind": "manual"`` marker; ANY other shape (no marker, a managed record, a
|
|
158
|
+
malformed rec) is :data:`KIND_MANAGED` — the back-compat default (absence = managed)."""
|
|
159
|
+
if isinstance(rec, dict) and str(rec.get("kind") or "").strip().lower() == KIND_MANUAL:
|
|
160
|
+
return KIND_MANUAL
|
|
161
|
+
return KIND_MANAGED
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def read_strategy_kind(base_state_dir: str, strategy: str) -> str:
|
|
165
|
+
"""The persisted KIND of a strategy, read from its canonical/legacy wallet.json marker (J3).
|
|
166
|
+
A strategy with no wallet.json, or an unreadable one, is :data:`KIND_MANAGED` (a graph strategy
|
|
167
|
+
that has not been funded yet is managed; the marker only ever DISTINGUISHES manual). Never
|
|
168
|
+
throws — an unreadable marker degrades to managed rather than crash a sweep."""
|
|
169
|
+
import json # local: _layout stays dependency-light (pure pathing + this tolerant read)
|
|
170
|
+
|
|
171
|
+
wj_path, _layout = resolve_wallet_json(base_state_dir, strategy)
|
|
172
|
+
if wj_path is None:
|
|
173
|
+
return KIND_MANAGED
|
|
174
|
+
try:
|
|
175
|
+
rec = json.loads(wj_path.read_text(encoding="utf-8"))
|
|
176
|
+
except (OSError, json.JSONDecodeError):
|
|
177
|
+
return KIND_MANAGED
|
|
178
|
+
return wallet_kind(rec)
|
|
179
|
+
|
|
180
|
+
|
|
144
181
|
def iter_release_dirs(base_state_dir: str, strategy: str) -> list[tuple[Path, str]]:
|
|
145
182
|
"""Every LANDED release dir (one carrying an ``install.json``) for a strategy, across BOTH
|
|
146
183
|
layouts, tagged ``"canonical"`` / ``"legacy"``. Canonical dirs are listed first so a caller that
|