@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,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)
|