@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
@@ -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 cross-asset compare [target, benchmark]; two-tail signal exit.
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 cross-asset compare [target, benchmark]; two-tail signal exit.",
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
- def default_list_runtimes() -> list[Runtime]:
188
- """Production ListFn seam: ``openclaw senpi runtime list`` → parsed rows (empty on error —
189
- the runtime registry simply isn't visible from a box without openclaw)."""
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
- if not isinstance(raw, dict) or not clearinghouse_looks_parsable(raw):
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(raw))
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