@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,650 @@
1
+ """portfolio — the CROSS-STRATEGY view of ``composer status`` with NO target (slice J2).
2
+
3
+ ``composer status <strategy>`` diagnoses ONE strategy (H2). ``composer status`` with NO target is
4
+ the whole box's portfolio: every strategy the composer can account for (canonical layout + the
5
+ legacy-layout installs the existing fallback finds), each with its lifecycle stage, wallet, on-box
6
+ install/runtime state, and a per-position PROTECTION summary; PLUS the runtimes that are live or
7
+ registered on the box but derivable from NO composer record (stranded/unmanaged — rendered WITH
8
+ recovery text, never a bare "doesn't exist" beside a live runtime); PLUS a plain account-level /
9
+ manual-position section (J3 will formalize a manual `kind`).
10
+
11
+ This absorbs the ENGINE of the separately-versioned ``senpi-portfolio`` skill so that script can be
12
+ deleted in a follow-up slice. It corrects that engine's three field-proven defects by CONSTRUCTION:
13
+
14
+ 1. HOME-vs-gateway state root — there is no ``~/.openclaw/senpi-state`` guess anywhere; the state
15
+ root is threaded in by the rim (CMP248 refuses rather than guess). See composer/cli.py.
16
+ 2. mis-parsed ratchet list → false "not yet armed" — protection is read from the BOX ENGINE's
17
+ ``getSystemState`` DSL positions (``currentTierIndex`` / ``floorPrice`` / ``slOrderId``), never
18
+ reconstructed from a second ``ratchet_stop_list`` call whose container key the live API changed.
19
+ ``_live.is_tier_armed`` derives "armed" from ``currentTierIndex`` directly.
20
+ 3. fail-OPEN reassurance — unavailable data renders as "unavailable (reason)" here, never a
21
+ confident default. A running strategy whose gateway read failed says so (CMP259); a strategy
22
+ with no live read says "not read (not running)"; the manual section says why it is unavailable.
23
+
24
+ SINGLE PRODUCER + ENGINE QUOTING (interim rule). DSL/protection state is QUOTED from the BOX ENGINE
25
+ (``getSystemState`` via the H2 seam) and the render NAMES it as such. Where a backend record is also
26
+ reachable through an EXISTING seam (the clearinghouse open-positions reader, ``_box.PositionsFn``)
27
+ and DISAGREES with the box engine, a divergence warning naming BOTH sides is printed. The portfolio
28
+ sweep does NOT fan that backend call out across the whole fleet by default (it is cred-gated and
29
+ per-wallet); it is wired through an injectable ``positions_fn`` (tests inject it; per-strategy
30
+ ``composer status <strategy>`` is the live+backend detail surface). No NEW backend call is added.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+ from typing import Callable, Optional
39
+
40
+ from . import _box
41
+ from ._box import ListFn, PositionsFn
42
+ from ._layout import (
43
+ COMPOSER_DIR,
44
+ GRAPH_FILE,
45
+ KIND_MANAGED,
46
+ KIND_MANUAL,
47
+ LEGACY_STRATEGIES_DIR,
48
+ RELEASES_DIR,
49
+ STAGED_DIR,
50
+ WALLET_FILE,
51
+ iter_release_dirs,
52
+ resolve_wallet_json,
53
+ wallet_kind,
54
+ )
55
+ from ._live import (
56
+ LiveState,
57
+ SystemStateFn,
58
+ default_system_state,
59
+ human_tier,
60
+ is_tier_armed,
61
+ _pct,
62
+ _price,
63
+ )
64
+ from .status import (
65
+ InstallView,
66
+ StatusResult,
67
+ _all_managed_runtime_ids,
68
+ _read_registry_entries,
69
+ run_status,
70
+ )
71
+
72
+ # The name of the engine whose DSL/protection state the portfolio QUOTES (interim single-producer
73
+ # rule until the dual-engine ownership contract lands). Printed in the render so an operator always
74
+ # knows which producer the protection numbers came from.
75
+ BOX_ENGINE = "box engine"
76
+
77
+ # A wallet -> account/manual positions reader. Returns the account-level open positions NOT owned by
78
+ # a composer strategy (or None when no such source is wired). J3 formalizes the manual `kind`; until
79
+ # then the default is None (unavailable-with-reason), and a test/future caller injects a reader.
80
+ ManualPositionsFn = Callable[[], Optional[list[dict]]]
81
+
82
+
83
+ # --------------------------------------------------------------------------- position summary
84
+
85
+ @dataclass
86
+ class PortfolioPosition:
87
+ """One open position's compact PROTECTION summary, quoted from the box engine's live DSL state.
88
+ ``armed`` is derived from ``currentTierIndex`` directly (never a mis-parsed ratchet list)."""
89
+
90
+ asset: str
91
+ dex: Optional[str]
92
+ direction: Optional[str]
93
+ leverage: Optional[float]
94
+ roe: Optional[float]
95
+ armed: bool
96
+ tier_human: str
97
+ floor_price: Optional[float]
98
+ sl_order_id: Optional[object]
99
+
100
+ def to_json(self) -> dict:
101
+ return {
102
+ "asset": self.asset,
103
+ "dex": self.dex,
104
+ "direction": self.direction,
105
+ "leverage": self.leverage,
106
+ "roe": self.roe,
107
+ "armed": self.armed,
108
+ "tier": self.tier_human,
109
+ "floor_price": self.floor_price,
110
+ "sl_order_id": self.sl_order_id,
111
+ }
112
+
113
+ def render(self) -> str:
114
+ book = f"@{self.dex}" if self.dex and self.dex != "main" else ""
115
+ lev = f" {_numish(self.leverage)}x" if self.leverage is not None else ""
116
+ roe = f" ROE {_pct(self.roe, signed=True)}" if self.roe is not None else ""
117
+ if self.armed:
118
+ prot = f"ARMED {self.tier_human}, floor {_price(self.floor_price)}"
119
+ else:
120
+ prot = f"phase 1 ({self.tier_human}), floor {_price(self.floor_price)}"
121
+ sl = (f", exchange stop #{self.sl_order_id}" if self.sl_order_id not in (None, "")
122
+ else ", exchange stop not yet placed")
123
+ return f"{self.asset}{book} {self.direction or '?'}{lev}{roe} — {prot}{sl}"
124
+
125
+
126
+ def _summarize_position(p: dict) -> PortfolioPosition:
127
+ tiers = p.get("tiers") or []
128
+ tier_idx = p.get("currentTierIndex")
129
+ return PortfolioPosition(
130
+ asset=str(p.get("asset") or "?"),
131
+ dex=p.get("dex"),
132
+ direction=p.get("direction"),
133
+ leverage=p.get("leverage"),
134
+ roe=p.get("currentROE"),
135
+ armed=is_tier_armed(p),
136
+ tier_human=human_tier(tier_idx, len(tiers)),
137
+ floor_price=p.get("floorPrice"),
138
+ sl_order_id=p.get("slOrderId"),
139
+ )
140
+
141
+
142
+ # --------------------------------------------------------------------------- per-strategy row
143
+
144
+ @dataclass
145
+ class PortfolioStrategy:
146
+ """One strategy's portfolio row: lifecycle stage, wallet, on-box install/runtime state, and the
147
+ box-engine protection summary of its open positions (with any backend divergence warnings)."""
148
+
149
+ strategy: str
150
+ layout: str # "canonical" | "legacy"
151
+ position: str
152
+ next_verb: str
153
+ next_hint: str
154
+ wallet: Optional[str] = None
155
+ runtime_id: Optional[str] = None
156
+ runtime_state: str = "none" # running | registered | landed | none
157
+ live_available: Optional[bool] = None # None = not read (not running); True/False when read
158
+ live_reason: str = ""
159
+ positions: list[PortfolioPosition] = field(default_factory=list)
160
+ divergences: list[str] = field(default_factory=list)
161
+ split_warning: Optional[str] = None
162
+ # J3: the strategy KIND predicate. Managed rows carry protection; MANUAL rows have NO runtime,
163
+ # so they render a wallet + backend positions and no protection lines.
164
+ kind: str = KIND_MANAGED
165
+ manual_positions: list[dict] = field(default_factory=list) # a MANUAL wallet's open positions
166
+ manual_reason: str = "" # why a manual wallet's positions are unavailable (LOUD)
167
+
168
+ def to_json(self) -> dict:
169
+ out: dict = {
170
+ "strategy": self.strategy,
171
+ "layout": self.layout,
172
+ "kind": self.kind,
173
+ "position": self.position,
174
+ "next_verb": self.next_verb,
175
+ "next": self.next_hint,
176
+ "wallet": self.wallet,
177
+ "runtime_id": self.runtime_id,
178
+ "runtime_state": self.runtime_state,
179
+ "protection_source": BOX_ENGINE,
180
+ "live": (None if self.live_available is None
181
+ else {"available": self.live_available, "reason": self.live_reason}),
182
+ "positions": [p.to_json() for p in self.positions],
183
+ }
184
+ if self.kind == KIND_MANUAL:
185
+ out["manual_positions"] = {
186
+ "available": not self.manual_reason,
187
+ "reason": self.manual_reason,
188
+ "positions": list(self.manual_positions),
189
+ }
190
+ if self.divergences:
191
+ out["divergences"] = list(self.divergences)
192
+ if self.split_warning:
193
+ out["split_warning"] = self.split_warning
194
+ return out
195
+
196
+ def render(self) -> list[str]:
197
+ if self.kind == KIND_MANUAL:
198
+ return self._render_manual()
199
+ wallet = self.wallet or "unbound"
200
+ rt = self.runtime_id or "-"
201
+ lines = [
202
+ f" {self.strategy} [{self.layout}]",
203
+ f" stage : {self.position} (next: {self.next_verb})",
204
+ f" wallet : {wallet}",
205
+ f" runtime : {self.runtime_state} ({rt})",
206
+ ]
207
+ # protection is QUOTED from the box engine; unavailability is LOUD, never a default.
208
+ if self.live_available is None:
209
+ lines.append(" protection : not read (runtime not running on this box)")
210
+ elif not self.live_available:
211
+ lines.append(f" protection : UNAVAILABLE [CMP259] ({self.live_reason}) "
212
+ f"— NOT assuming protected")
213
+ elif not self.positions:
214
+ lines.append(" protection : no open positions under DSL management")
215
+ else:
216
+ lines.append(f" protection : {len(self.positions)} open position(s) "
217
+ f"(quoted from the {BOX_ENGINE}):")
218
+ for p in self.positions:
219
+ lines.append(f" - {p.render()}")
220
+ for d in self.divergences:
221
+ lines.append(f" !! DIVERGENCE: {d}")
222
+ if self.split_warning:
223
+ lines.append(f" !! {self.split_warning}")
224
+ return lines
225
+
226
+ def _render_manual(self) -> list[str]:
227
+ """A MANUAL strategy's row (J3): labeled as its own kind, NO runtime/protection lines (a
228
+ manual strategy has no runtime), and the wallet's open positions when the seam is wired."""
229
+ lines = [
230
+ f" {self.strategy} [manual]",
231
+ f" kind : manual (wallet under composer record; NO runtime — by design)",
232
+ f" wallet : {self.wallet or 'unbound'}",
233
+ " runtime : none (manual strategies have no runtime; direct MCP position edits "
234
+ "are the sanctioned path)",
235
+ ]
236
+ if self.manual_reason:
237
+ lines.append(f" positions : not read ({self.manual_reason})")
238
+ elif not self.manual_positions:
239
+ lines.append(" positions : none open on this wallet")
240
+ else:
241
+ lines.append(f" positions : {len(self.manual_positions)} open on this wallet:")
242
+ for p in self.manual_positions:
243
+ asset = p.get("coin") or p.get("asset") or "?"
244
+ dex = p.get("dex")
245
+ book = f"@{dex}" if dex and dex != "main" else ""
246
+ sz = p.get("szi") if p.get("szi") is not None else p.get("size")
247
+ lines.append(f" - {asset}{book} size={sz}")
248
+ return lines
249
+
250
+
251
+ # --------------------------------------------------------------------------- stranded runtimes
252
+
253
+ @dataclass
254
+ class StrandedRuntime:
255
+ """A runtime that is LIVE (in ``runtime list``) and/or REGISTERED (installed_runtimes.json) but
256
+ derivable from NO composer install record — rendered WITH recovery text so status never reports
257
+ "doesn't exist" beside a runtime the box is actually running (field triage #2)."""
258
+
259
+ runtime_id: str
260
+ live: bool
261
+ registered: bool
262
+ wallet: Optional[str] = None
263
+ status: Optional[str] = None
264
+
265
+ @property
266
+ def source(self) -> str:
267
+ if self.live and self.registered:
268
+ return "live + registered"
269
+ if self.live:
270
+ return "live (runtime list)"
271
+ return "registered (installed_runtimes.json)"
272
+
273
+ @property
274
+ def recovery(self) -> str:
275
+ return (
276
+ "no composer graph produced this runtime. It is real on the box — do NOT treat it as "
277
+ "absent. Either ADOPT it (author a graph.yaml for this strategy, `composer deploy` + "
278
+ "`composer install` onto the SAME wallet to bring it under management) or TEAR IT DOWN "
279
+ f"(`openclaw senpi runtime delete --id {self.runtime_id}`"
280
+ + (f" --address {self.wallet}" if self.wallet else "")
281
+ + "). `composer close` only handles composer-managed strategies."
282
+ )
283
+
284
+ def to_json(self) -> dict:
285
+ return {
286
+ "runtime_id": self.runtime_id,
287
+ "live": self.live,
288
+ "registered": self.registered,
289
+ "source": self.source,
290
+ "wallet": self.wallet,
291
+ "status": self.status,
292
+ "recovery": self.recovery,
293
+ }
294
+
295
+
296
+ # --------------------------------------------------------------------------- portfolio result
297
+
298
+ @dataclass
299
+ class PortfolioResult:
300
+ base_state_dir: str
301
+ strategies: list[PortfolioStrategy] = field(default_factory=list)
302
+ stranded_runtimes: list[StrandedRuntime] = field(default_factory=list)
303
+ manual_available: bool = False
304
+ manual_reason: str = ""
305
+ manual_positions: list[dict] = field(default_factory=list)
306
+
307
+ def to_json(self) -> dict:
308
+ return {
309
+ "verb": "status",
310
+ "scope": "portfolio",
311
+ "state_dir": self.base_state_dir,
312
+ "protection_source": BOX_ENGINE,
313
+ "strategy_count": len(self.strategies),
314
+ "strategies": [s.to_json() for s in self.strategies],
315
+ "stranded_runtimes": [r.to_json() for r in self.stranded_runtimes],
316
+ "manual_positions": {
317
+ "available": self.manual_available,
318
+ "reason": self.manual_reason,
319
+ "positions": list(self.manual_positions),
320
+ },
321
+ }
322
+
323
+ def render(self) -> str:
324
+ lines = [
325
+ f"portfolio status (state: {self.base_state_dir})",
326
+ f"protection quoted from the {BOX_ENGINE} (getSystemState); unavailable data is shown "
327
+ f"as such, never assumed protected.",
328
+ "",
329
+ f"strategies ({len(self.strategies)}):",
330
+ ]
331
+ if not self.strategies:
332
+ lines.append(" (none — no strategy under <state>/composer or a landed <state>/strategies "
333
+ "install)")
334
+ for s in self.strategies:
335
+ lines.extend(s.render())
336
+ # stranded / unmanaged runtimes — WITH recovery text (never a bare "doesn't exist").
337
+ lines.append("")
338
+ if self.stranded_runtimes:
339
+ lines.append(f"stranded runtimes ({len(self.stranded_runtimes)}) — live/registered on "
340
+ f"the box, NO composer record:")
341
+ for r in self.stranded_runtimes:
342
+ lines.append(f" - {r.runtime_id} [{r.source}]"
343
+ + (f" wallet={r.wallet}" if r.wallet else "")
344
+ + (f" status={r.status}" if r.status else ""))
345
+ lines.append(f" recovery: {r.recovery}")
346
+ else:
347
+ lines.append("stranded runtimes: none (every live/registered runtime maps to a composer "
348
+ "record)")
349
+ # manual / account-level positions — plain section, LOUD when unavailable (J3 formalizes it).
350
+ lines.append("")
351
+ lines.append("account-level / manual positions (not attributed to any strategy):")
352
+ if not self.manual_available:
353
+ lines.append(f" unavailable ({self.manual_reason})")
354
+ elif not self.manual_positions:
355
+ lines.append(" none")
356
+ else:
357
+ for p in self.manual_positions:
358
+ asset = p.get("asset") or p.get("coin") or "?"
359
+ direction = p.get("direction") or ""
360
+ dex = p.get("dex")
361
+ book = f"@{dex}" if dex and dex != "main" else ""
362
+ sz = p.get("szi") if p.get("szi") is not None else p.get("size")
363
+ lines.append(f" - {asset}{book} {direction} size={sz}")
364
+ return "\n".join(lines) + "\n"
365
+
366
+
367
+ # --------------------------------------------------------------------------- enumeration
368
+
369
+ def _has_canonical_artifacts(strategy_dir: Path) -> bool:
370
+ return (
371
+ (strategy_dir / GRAPH_FILE).is_file()
372
+ or (strategy_dir / STAGED_DIR).is_dir()
373
+ or (strategy_dir / RELEASES_DIR).is_dir()
374
+ )
375
+
376
+
377
+ def _is_manual_dir(strategy_dir: Path) -> bool:
378
+ """J3: a MANUAL strategy dir — a canonical dir owning ONLY a ``wallet.json`` marked
379
+ ``kind: manual`` (no graph/staged/releases). Enumerated so a manual strategy is accountable in
380
+ the portfolio despite carrying none of the graph-lifecycle artifacts."""
381
+ wj = strategy_dir / WALLET_FILE
382
+ if not wj.is_file():
383
+ return False
384
+ try:
385
+ import json as _json
386
+ rec = _json.loads(wj.read_text(encoding="utf-8"))
387
+ except (OSError, ValueError):
388
+ return False
389
+ return wallet_kind(rec) == KIND_MANUAL
390
+
391
+
392
+ def enumerate_strategies(state_dir: Path) -> list[str]:
393
+ """Every strategy name the composer can account for: canonical dirs under ``<state>/composer``
394
+ carrying a graph.yaml / staged/ / releases/ (managed) OR a ``kind: manual`` wallet.json (J3
395
+ manual), PLUS legacy strategies under ``<state>/strategies`` with at least one LANDED release
396
+ (the existing read-side fallback). Sorted, de-duped."""
397
+ names: set[str] = set()
398
+ comp = state_dir / COMPOSER_DIR
399
+ if comp.is_dir():
400
+ for child in comp.iterdir():
401
+ if child.is_dir() and (_has_canonical_artifacts(child) or _is_manual_dir(child)):
402
+ names.add(child.name)
403
+ legacy = state_dir / LEGACY_STRATEGIES_DIR
404
+ if legacy.is_dir():
405
+ for child in legacy.iterdir():
406
+ if child.is_dir() and iter_release_dirs(str(state_dir), child.name):
407
+ names.add(child.name)
408
+ return sorted(names)
409
+
410
+
411
+ # --------------------------------------------------------------------------- build one row
412
+
413
+ def _resolve_wallet(state_dir: Path, strategy: str, current: Optional[InstallView]) -> Optional[str]:
414
+ if current is not None and current.wallet:
415
+ return current.wallet
416
+ wj_path, _layout = resolve_wallet_json(str(state_dir), strategy)
417
+ if wj_path is not None:
418
+ try:
419
+ data = json.loads(wj_path.read_text(encoding="utf-8"))
420
+ w = data.get("wallet")
421
+ return str(w) if w else None
422
+ except (OSError, json.JSONDecodeError):
423
+ return None
424
+ return None
425
+
426
+
427
+ def _cross_check_divergences(box_positions: list[PortfolioPosition], wallet: str,
428
+ positions_fn: PositionsFn) -> list[str]:
429
+ """Cross-check the BOX ENGINE's protected positions against the backend clearinghouse (an
430
+ EXISTING seam). Names BOTH sides on any disagreement; fails LOUD when the backend is unverifiable
431
+ (never silently assumes agreement)."""
432
+ res = positions_fn(wallet)
433
+ if not res.verified:
434
+ return [f"backend cross-check unavailable ({res.reason}) — the {BOX_ENGINE} protection above "
435
+ f"is UNCONFIRMED against the exchange clearinghouse"]
436
+ box_assets = {p.asset.upper() for p in box_positions}
437
+ backend_assets = {str(r.get("coin") or "").upper() for r in res.positions if r.get("coin")}
438
+ out: list[str] = []
439
+ for asset in sorted(box_assets - backend_assets):
440
+ out.append(f"{asset}: {BOX_ENGINE} reports it OPEN + under protection, but the backend "
441
+ f"clearinghouse shows NO open {asset} position (protection state may be stale / "
442
+ f"the position already closed)")
443
+ for asset in sorted(backend_assets - box_assets):
444
+ out.append(f"{asset}: the backend clearinghouse shows an OPEN {asset} position that the "
445
+ f"{BOX_ENGINE} is NOT protecting on this wallet (manual/unmanaged position)")
446
+ return out
447
+
448
+
449
+ def _effective_install(st: StatusResult) -> Optional[InstallView]:
450
+ """The install a portfolio row speaks for: the deployed-current one if run_status marked it,
451
+ else the RUNNING install, else the first landed one. (An installed-only strategy with no
452
+ canonical graph.yaml has no ``is_current`` hash to match, so run_status leaves current_install
453
+ None even while a runtime is running — the portfolio must still speak for that running install.)"""
454
+ if st.current_install is not None:
455
+ return st.current_install
456
+ running = next((i for i in st.installs if i.running), None)
457
+ if running is not None:
458
+ return running
459
+ return st.installs[0] if st.installs else None
460
+
461
+
462
+ def _build_strategy_row(state_dir: Path, strategy: str, st: StatusResult,
463
+ state_fn: SystemStateFn,
464
+ positions_fn: Optional[PositionsFn]) -> PortfolioStrategy:
465
+ current = _effective_install(st)
466
+ layout = current.layout if current is not None else (
467
+ "canonical" if (state_dir / COMPOSER_DIR / strategy).is_dir() else "legacy"
468
+ )
469
+ runtime_state = current.state if current is not None else "none"
470
+ runtime_id = current.runtime_id if current is not None else None
471
+ wallet = _resolve_wallet(state_dir, strategy, current)
472
+
473
+ row = PortfolioStrategy(
474
+ strategy=strategy,
475
+ layout=layout,
476
+ position=st.position,
477
+ next_verb=st.next_verb,
478
+ next_hint=st.next_hint,
479
+ wallet=wallet,
480
+ runtime_id=runtime_id,
481
+ runtime_state=runtime_state,
482
+ split_warning=st.split_warning,
483
+ kind=st.kind,
484
+ )
485
+
486
+ # J3: a MANUAL strategy has NO runtime — there is no box-engine protection to quote. Attribute
487
+ # its wallet's open positions via the EXISTING clearinghouse seam when wired (default None => not
488
+ # read, LOUD), then return — none of the managed live/protection logic below applies.
489
+ if st.kind == KIND_MANUAL:
490
+ if positions_fn is not None and wallet:
491
+ try:
492
+ res = positions_fn(wallet)
493
+ except Exception as e: # noqa: BLE001 — a seam fault must not break the sweep
494
+ row.manual_reason = f"positions read raised ({e})"
495
+ else:
496
+ if res.verified:
497
+ row.manual_positions = list(res.positions)
498
+ else:
499
+ row.manual_reason = res.reason or "positions unverifiable"
500
+ else:
501
+ row.manual_reason = "not read (no positions source wired at the portfolio level)"
502
+ return row
503
+
504
+ # PROTECTION, quoted from the box engine. run_status attaches live only for the deployed-current
505
+ # install; for a running install it didn't mark current, fetch it here (same seam). None => not
506
+ # running (not read). available False => gateway read failed (CMP259) — LOUD, never assumed.
507
+ live = st.live
508
+ if live is None and current is not None and current.running and current.runtime_id:
509
+ try:
510
+ live = state_fn(current.runtime_id)
511
+ except Exception as e: # noqa: BLE001 — the seam should not throw; never crash the sweep
512
+ live = LiveState(False, reason=f"live read raised ({e})", runtime_id=current.runtime_id)
513
+
514
+ if live is None:
515
+ row.live_available = None
516
+ elif not live.available:
517
+ row.live_available = False
518
+ row.live_reason = live.reason
519
+ else:
520
+ row.live_available = True
521
+ row.positions = [_summarize_position(p) for p in live.positions]
522
+ # divergence cross-check against the backend, only if a seam is wired + a wallet is known.
523
+ if positions_fn is not None and wallet:
524
+ try:
525
+ row.divergences = _cross_check_divergences(row.positions, wallet, positions_fn)
526
+ except Exception as e: # noqa: BLE001 — a cross-check fault must not break the sweep
527
+ row.divergences = [f"backend cross-check raised ({e}) — protection UNCONFIRMED"]
528
+ return row
529
+
530
+
531
+ # --------------------------------------------------------------------------- stranded derivation
532
+
533
+ def _derive_stranded(state_dir: Path, runtimes: list[dict]) -> list[StrandedRuntime]:
534
+ """Runtimes LIVE (runtime list) and/or REGISTERED (installed_runtimes.json) with no composer
535
+ install record. Cross-checks BOTH the live registry AND the on-disk registry before concluding a
536
+ runtime is unmanaged (field triage #2)."""
537
+ managed = _all_managed_runtime_ids(state_dir)
538
+ live_by_id: dict[str, dict] = {}
539
+ for r in runtimes:
540
+ rid = r.get("name")
541
+ if rid:
542
+ live_by_id.setdefault(str(rid), r)
543
+ registered_ids = {str(e.get("id")) for e in _read_registry_entries(state_dir) if e.get("id")}
544
+
545
+ out: list[StrandedRuntime] = []
546
+ for rid in sorted(set(live_by_id) | registered_ids):
547
+ if rid in managed:
548
+ continue
549
+ live_row = live_by_id.get(rid)
550
+ out.append(StrandedRuntime(
551
+ runtime_id=rid,
552
+ live=live_row is not None,
553
+ registered=rid in registered_ids,
554
+ wallet=(live_row.get("wallet") if live_row else None),
555
+ status=(live_row.get("status") if live_row else None),
556
+ ))
557
+ return out
558
+
559
+
560
+ # --------------------------------------------------------------------------- entry point
561
+
562
+ def run_portfolio_status(
563
+ base_state_dir: str,
564
+ *,
565
+ list_fn: ListFn = _box.default_list_runtimes,
566
+ state_fn: SystemStateFn = default_system_state,
567
+ positions_fn: Optional[PositionsFn] = None,
568
+ manual_positions_fn: Optional[ManualPositionsFn] = None,
569
+ ) -> PortfolioResult:
570
+ """The cross-strategy portfolio view (bare ``composer status``). Enumerates every accountable
571
+ strategy (reusing the per-strategy ``run_status`` derivation — the single producer), lists
572
+ stranded/unmanaged runtimes WITH recovery text, and renders a plain manual-positions section.
573
+
574
+ ``positions_fn`` (default None) opts INTO the backend divergence cross-check via the existing
575
+ clearinghouse seam; the default sweep does not fan it out across the fleet. ``manual_positions_fn``
576
+ (default None) supplies account-level positions; without it the section is unavailable-with-reason
577
+ (J3 formalizes the manual kind)."""
578
+ state_dir = Path(base_state_dir)
579
+ result = PortfolioResult(base_state_dir=str(state_dir))
580
+
581
+ # ONE runtime-list read, shared by the per-strategy rows and the stranded derivation.
582
+ try:
583
+ runtimes = list_fn()
584
+ except Exception: # noqa: BLE001 — a listing fault degrades to no live/stranded info, not a crash
585
+ runtimes = []
586
+ shared_list: ListFn = lambda: runtimes # noqa: E731
587
+
588
+ # J9: the live system-state seam is MEMOIZED across the whole sweep, keyed by runtime id, so a
589
+ # bare status with N strategies consumes ONE pre-fetched state document and never fetches per row
590
+ # (the rim serves ALL rows from a single ``senpi.getSystemState`` it fetched once — see
591
+ # _live.default_system_state). The injectable seam is preserved verbatim for tests; this only
592
+ # collapses repeat reads of the SAME runtime.
593
+ _state_cache: dict = {}
594
+
595
+ def shared_state_fn(rid, _fn=state_fn, _cache=_state_cache):
596
+ if rid not in _cache:
597
+ _cache[rid] = _fn(rid)
598
+ return _cache[rid]
599
+
600
+ for strategy in enumerate_strategies(state_dir):
601
+ st = run_status(strategy, state_dir=str(state_dir),
602
+ list_fn=shared_list, state_fn=shared_state_fn)
603
+ result.strategies.append(
604
+ _build_strategy_row(state_dir, strategy, st, shared_state_fn, positions_fn))
605
+
606
+ result.stranded_runtimes = _derive_stranded(state_dir, runtimes)
607
+
608
+ # manual / account-level positions — plain, LOUD when unavailable.
609
+ if manual_positions_fn is None:
610
+ result.manual_available = False
611
+ result.manual_reason = (
612
+ "no account-position source is wired at the portfolio level — the box engine only "
613
+ "tracks strategy-attributed positions. J3 will formalize a manual `kind`."
614
+ )
615
+ else:
616
+ try:
617
+ positions = manual_positions_fn()
618
+ except Exception as e: # noqa: BLE001
619
+ result.manual_available = False
620
+ result.manual_reason = f"account-position read raised ({e})"
621
+ else:
622
+ if positions is None:
623
+ result.manual_available = False
624
+ result.manual_reason = "account-position source returned no data"
625
+ else:
626
+ # attribute-out anything a strategy already tracks (by asset), leaving the manual
627
+ # set. J3: this now also attributes positions on MANUAL-strategy wallets to those
628
+ # strategies (via the same clearinghouse seam), so a manual strategy's own positions
629
+ # are NOT double-counted in the unattributed account-level section.
630
+ attributed = {p.asset.upper()
631
+ for s in result.strategies for p in s.positions}
632
+ attributed |= {
633
+ str(mp.get("coin") or mp.get("asset") or "").upper()
634
+ for s in result.strategies for mp in s.manual_positions
635
+ if (mp.get("coin") or mp.get("asset"))
636
+ }
637
+ result.manual_available = True
638
+ result.manual_positions = [
639
+ p for p in positions
640
+ if str(p.get("asset") or p.get("coin") or "").upper() not in attributed
641
+ ]
642
+ return result
643
+
644
+
645
+ def _numish(v) -> str:
646
+ if isinstance(v, bool):
647
+ return str(v)
648
+ if isinstance(v, float) and v.is_integer():
649
+ return str(int(v))
650
+ return str(v)