@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,724 @@
|
|
|
1
|
+
"""review — retrospective TRADE-QUALITY analysis for ``composer review [<strategy>]`` (slice J8).
|
|
2
|
+
|
|
3
|
+
``composer review`` is the SINGLE PRODUCER of trade-quality analysis: it absorbs the ENGINE of the
|
|
4
|
+
separately-versioned ``senpi-improve-trades`` skill (its ``review.py``) so that script can be deleted
|
|
5
|
+
in a follow-up slice. Per strategy it reconstructs the CLOSED-trade record (count / win rate / realized
|
|
6
|
+
PnL), enriches each exit with the box EVENT LOG's exit reason, tallies leak findings, and — when the
|
|
7
|
+
graph carries a ``metadata.spec.thesis`` — quotes that mandate next to the observed behaviour. Bare
|
|
8
|
+
``composer review`` sweeps every accountable strategy and adds the cross-strategy realized-PnL
|
|
9
|
+
aggregate (the realized-PnL / trade-history views the retired ``senpi-portfolio`` skill left un-homed).
|
|
10
|
+
|
|
11
|
+
It corrects the improve-trades engine's TWO field-proven defects BY CONSTRUCTION:
|
|
12
|
+
|
|
13
|
+
1. ZERO-TRADES LABEL ARTIFACT — review.py set ``meta.telemetry_source="unavailable"`` whenever zero
|
|
14
|
+
events enriched zero closed trades, so a user with NO closed trades read "Telemetry is
|
|
15
|
+
unavailable" on a perfectly healthy pipeline. Here a strategy with no closed trades renders an
|
|
16
|
+
honest "no closed trades yet — nothing to review", and telemetry availability is NEVER conflated
|
|
17
|
+
with nothing-to-enrich: the two are separate, separately-reported facts.
|
|
18
|
+
2. CLI COLD-BOOT TIMEOUT — review.py shelled ``openclaw senpi events --runtime <id>`` per strategy
|
|
19
|
+
(review.py:485), each a full CLI + plugins + OTel boot (~10-15s) fanned 8-wide, so every read
|
|
20
|
+
timed out and the engine reported telemetry unavailable while the data was fine. This verb makes
|
|
21
|
+
ZERO shell-outs to the openclaw CLI for events: the rim serves the per-runtime event rings
|
|
22
|
+
IN-PROCESS via the ``events`` section of the live document (composer-live-read.ts::getEventsByRuntime).
|
|
23
|
+
When that section is absent (offline / direct invocation) events render "unavailable (no live
|
|
24
|
+
channel — run via the rim)" — there is deliberately NO events subprocess fallback (unlike
|
|
25
|
+
state/list, an event ring has no sanctioned cold-boot path for a hot loop).
|
|
26
|
+
|
|
27
|
+
FAIL-LOUD honesty doctrine (settled across the composer verbs): every unavailable input renders
|
|
28
|
+
"unavailable (reason)"; a per-strategy transport failure (history read) degrades THAT strategy, never
|
|
29
|
+
the sweep; zero closed trades is the honest empty message; and every quoted figure NAMES its source
|
|
30
|
+
(backend trade history / box event log).
|
|
31
|
+
|
|
32
|
+
All I/O is an INJECTABLE SEAM (mirroring ``_box``/``_live``): ``history_fn`` (wallet -> closed-trade
|
|
33
|
+
rows) and ``events_fn`` (the live document's ``events`` section) are faked in tests; production wires
|
|
34
|
+
``default_closed_history`` (the in-process MCP seam) and the live-document reader. No test spawns
|
|
35
|
+
anything.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Callable, Optional
|
|
43
|
+
|
|
44
|
+
from . import _box
|
|
45
|
+
from ._box import McpFn, default_mcp_call
|
|
46
|
+
from ._layout import canonical_graph_path
|
|
47
|
+
from ._live import LiveState
|
|
48
|
+
from .portfolio import _effective_install, _resolve_wallet, enumerate_strategies
|
|
49
|
+
from .status import run_status
|
|
50
|
+
|
|
51
|
+
# The named sources every figure is attributed to (printed so an operator always knows the producer).
|
|
52
|
+
SRC_HISTORY = "backend trade history" # discovery_get_trader_history (onchain closed ledger)
|
|
53
|
+
SRC_EVENTS = "box event log" # the runtime's on-disk event ring (dsl/position/order/…)
|
|
54
|
+
|
|
55
|
+
CLOSED_HISTORY_PULL = 100 # closed positions to pull per wallet (review.py parity)
|
|
56
|
+
|
|
57
|
+
# EXIT attribution — the exit events a closed trade is matched against (references/event-log.md shapes).
|
|
58
|
+
_EXIT_EVENT_NAMES = ("dsl.closed", "position.closed")
|
|
59
|
+
EXIT_MATCH_WINDOW_MS = 120000.0 # ±2-min asset+time fallback when there is no order id
|
|
60
|
+
|
|
61
|
+
# PREMATURE-exit label (folded from review.py::_is_premature_exit) — the "shaken out too early" cohort.
|
|
62
|
+
_PREMATURE_TERMINALS = ("trailing_floor", "weak_peak", "max_retrace")
|
|
63
|
+
_PREMATURE_TIER_MAX = 1
|
|
64
|
+
_PREMATURE_ROE_MAX = 5.0
|
|
65
|
+
|
|
66
|
+
# LEAK scan (folded from review.py::_scan_leak_and_fill_events, minus the maker/taker fee tally — a
|
|
67
|
+
# separate concern deferred). Protection gaps + failed orders + risk halts, from the same event ring.
|
|
68
|
+
_LEAK_ORDER_FAILED = "order.failed"
|
|
69
|
+
_LEAK_PROTECTION = ("dsl.sl_sync_failed", "dsl.handoff_failed")
|
|
70
|
+
_LEAK_PAUSED = "runtime.paused"
|
|
71
|
+
_LEAK_SAMPLE_CAP = 5
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------- seams
|
|
75
|
+
|
|
76
|
+
# A wallet -> raw closed-position rows reader (discovery_get_trader_history). Injectable so tests
|
|
77
|
+
# fake it; production wires ``default_closed_history`` (the in-process MCP seam). Returns the raw
|
|
78
|
+
# rows; parsing lives in ``_parse_closed_trades`` so the seam stays a thin transport.
|
|
79
|
+
HistoryFn = Callable[[str], list[dict]]
|
|
80
|
+
|
|
81
|
+
# A reader for the live document's ``events`` section (``{ok, by_runtime}`` | ``{ok:false, reason}``),
|
|
82
|
+
# or None when no live channel was handed down (offline / direct CLI). NEVER a subprocess (hard rule).
|
|
83
|
+
EventsFn = Callable[[], Optional[dict]]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class HistoryUnavailable(Exception):
|
|
87
|
+
"""A closed-history transport/credential failure for ONE wallet — carried so the sweep can render
|
|
88
|
+
that strategy's history as "unavailable (reason)" and move on, never crash the whole review."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def default_closed_history(wallet: str, mcp_fn: McpFn = default_mcp_call) -> list[dict]:
|
|
92
|
+
"""Production :data:`HistoryFn`: the wallet's closed-position ledger via the in-process MCP seam
|
|
93
|
+
(``discovery_get_trader_history`` — the SAME tool review.py read, reused through ``_box``'s
|
|
94
|
+
streamable-HTTP client, NOT a CLI boot). Raises :class:`HistoryUnavailable` on any transport /
|
|
95
|
+
credential / shape failure so the caller renders it LOUD (never a fabricated empty)."""
|
|
96
|
+
try:
|
|
97
|
+
raw = mcp_fn("discovery_get_trader_history", trader_address=wallet,
|
|
98
|
+
sort_by="CLOSED_TIME", sort_direction="DESC", limit=CLOSED_HISTORY_PULL)
|
|
99
|
+
except _box.McpCredsMissing as e:
|
|
100
|
+
raise HistoryUnavailable(f"MCP credentials missing ({e})") from e
|
|
101
|
+
except _box.McpError as e:
|
|
102
|
+
raise HistoryUnavailable(f"MCP error reading trade history ({e})") from e
|
|
103
|
+
except Exception as e: # noqa: BLE001 — a surprise transport fault is a datum, not a crash
|
|
104
|
+
raise HistoryUnavailable(f"could not read trade history ({e})") from e
|
|
105
|
+
data = _ok(raw)
|
|
106
|
+
if data is None:
|
|
107
|
+
raise HistoryUnavailable("trade history returned no data")
|
|
108
|
+
rows = data if isinstance(data, list) else _field(
|
|
109
|
+
data, "closedPositions", "closed_positions", "positions", default=[])
|
|
110
|
+
return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def default_events_section() -> Optional[dict]:
|
|
114
|
+
"""Production :data:`EventsFn`: the ``events`` section of the rim-served live document
|
|
115
|
+
(``SENPI_LIVE_FILE``). None when there is no live document at all OR it carries no ``events``
|
|
116
|
+
section (a direct/offline invocation) — the caller renders events "unavailable (no live channel)".
|
|
117
|
+
NEVER shells openclaw (the hard rule: an event ring has no sanctioned cold-boot path here)."""
|
|
118
|
+
doc = _box.load_live_document()
|
|
119
|
+
if not isinstance(doc, dict):
|
|
120
|
+
return None
|
|
121
|
+
section = doc.get("events")
|
|
122
|
+
return section if isinstance(section, dict) else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------- guarded IO (folded from review.py)
|
|
126
|
+
|
|
127
|
+
def _ok(resp):
|
|
128
|
+
if isinstance(resp, dict):
|
|
129
|
+
if resp.get("success") is False:
|
|
130
|
+
return None
|
|
131
|
+
return resp.get("data", resp)
|
|
132
|
+
return resp
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _num(v):
|
|
136
|
+
try:
|
|
137
|
+
return float(v)
|
|
138
|
+
except (TypeError, ValueError):
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _f(d, *keys, default=0.0):
|
|
143
|
+
if isinstance(d, dict):
|
|
144
|
+
for k in keys:
|
|
145
|
+
if k in d and d[k] is not None:
|
|
146
|
+
n = _num(d[k])
|
|
147
|
+
if n is not None:
|
|
148
|
+
return n
|
|
149
|
+
return default
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _field(d, *names, default=None):
|
|
153
|
+
if isinstance(d, dict):
|
|
154
|
+
for n in names:
|
|
155
|
+
if n in d and d[n] is not None:
|
|
156
|
+
return d[n]
|
|
157
|
+
return default
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _ms(ts):
|
|
161
|
+
"""Normalize a Unix timestamp to MILLISECONDS (folded from review.py::_ms — a seconds-valued
|
|
162
|
+
closeTime below ~1e12 is scaled ×1000, else it reads as pre-window and every trade drops)."""
|
|
163
|
+
n = _num(ts)
|
|
164
|
+
if n is None:
|
|
165
|
+
return None
|
|
166
|
+
return n * 1000.0 if n < 1e12 else n
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _direction(rec, szi, entry_px, exit_px, pnl):
|
|
170
|
+
"""Direction of a CLOSED trade (folded from review.py::_direction): explicit field → non-zero
|
|
171
|
+
szi sign → derive from realized PnL vs the price move. None when nothing resolves it."""
|
|
172
|
+
d = str(_field(rec, "dir", "side", "direction", "positionSide", default="") or "").strip().lower()
|
|
173
|
+
if d in ("long", "buy", "b", "bid", "l"):
|
|
174
|
+
return "long"
|
|
175
|
+
if d in ("short", "sell", "a", "ask", "s"):
|
|
176
|
+
return "short"
|
|
177
|
+
if szi and szi != 0:
|
|
178
|
+
return "long" if szi > 0 else "short"
|
|
179
|
+
move = (exit_px - entry_px) if (entry_px is not None and exit_px is not None) else None
|
|
180
|
+
if move and pnl:
|
|
181
|
+
return "long" if ((move > 0) == (pnl > 0)) else "short"
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _parse_closed_trades(rows: list[dict]) -> list[dict]:
|
|
186
|
+
"""Map raw discovery ``closedPositions`` rows into the compact trade shape the review reasons on
|
|
187
|
+
(folded from review.py::fetch_closed_trades, minus the window filter — the composer verb reviews
|
|
188
|
+
the recent closed ledger as a whole). Each trade: asset / direction / size / entry_px / exit_px /
|
|
189
|
+
realized_pnl / open_time / close_time / closed_order_id. Sorted newest-close first."""
|
|
190
|
+
trades: list[dict] = []
|
|
191
|
+
for p in rows:
|
|
192
|
+
if not isinstance(p, dict):
|
|
193
|
+
continue
|
|
194
|
+
szi = _f(p, "szi", "size", default=0.0)
|
|
195
|
+
entry_px = _num(_field(p, "entryPx", "entry_px"))
|
|
196
|
+
exit_px = _num(_field(p, "exitPx", "exit_px"))
|
|
197
|
+
pnl = round(_f(p, "realizedPnl", "realized_pnl", default=0.0), 2)
|
|
198
|
+
trades.append({
|
|
199
|
+
"asset": _field(p, "coin", "coinDisplayName", "asset"),
|
|
200
|
+
"direction": _direction(p, szi, entry_px, exit_px, pnl),
|
|
201
|
+
"size": abs(szi),
|
|
202
|
+
"entry_px": entry_px,
|
|
203
|
+
"exit_px": exit_px,
|
|
204
|
+
"realized_pnl": pnl,
|
|
205
|
+
"open_time": _ms(_field(p, "openTime", "open_time")),
|
|
206
|
+
"close_time": _ms(_field(p, "closeTime", "closed_time", "closeTimeMs")),
|
|
207
|
+
"closed_order_id": _field(p, "closedOrderId", "closed_order_id"),
|
|
208
|
+
})
|
|
209
|
+
trades.sort(key=lambda t: _num(t.get("close_time")) or 0, reverse=True)
|
|
210
|
+
return trades
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --------------------------------------------------------------------------- event enrichment (folded from review.py)
|
|
214
|
+
|
|
215
|
+
def _attr(ev, *keys, default=None):
|
|
216
|
+
attrs = ev.get("attrs") if isinstance(ev, dict) else None
|
|
217
|
+
if isinstance(attrs, dict):
|
|
218
|
+
for k in keys:
|
|
219
|
+
if k in attrs and attrs[k] is not None:
|
|
220
|
+
return attrs[k]
|
|
221
|
+
return default
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _index_exit_events(events: list[dict]):
|
|
225
|
+
"""Build the exit-event matcher (folded from review.py::_index_exit_events): by_order_id (exact)
|
|
226
|
+
+ by_asset (UPPER(asset) -> [(ts_ms, event)] for the ±2-min fallback)."""
|
|
227
|
+
by_order_id, by_asset = {}, {}
|
|
228
|
+
for ev in events:
|
|
229
|
+
if str(ev.get("name") or "") not in _EXIT_EVENT_NAMES:
|
|
230
|
+
continue
|
|
231
|
+
oid = _attr(ev, "senpi.order.id")
|
|
232
|
+
if oid:
|
|
233
|
+
by_order_id[str(oid)] = ev
|
|
234
|
+
asset = _attr(ev, "senpi.asset", "senpi.signal.asset")
|
|
235
|
+
ts = _num(ev.get("ts"))
|
|
236
|
+
if asset is not None and ts is not None:
|
|
237
|
+
by_asset.setdefault(str(asset).upper(), []).append((ts, ev))
|
|
238
|
+
return by_order_id, by_asset
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _exit_reason_from_event(ev) -> dict:
|
|
242
|
+
"""Read a telemetry exit event into the exit-reason contract (folded from review.py). Handles
|
|
243
|
+
both ``dsl.closed`` (senpi.dsl.*) and ``position.closed`` (senpi.position.*) shapes."""
|
|
244
|
+
terminal = _attr(ev, "senpi.dsl.close_reason", "senpi.position.close_reason")
|
|
245
|
+
tier_index = _attr(ev, "senpi.dsl.tier_index")
|
|
246
|
+
roe = _attr(ev, "senpi.dsl.current_roe", "senpi.position.roe")
|
|
247
|
+
return {
|
|
248
|
+
"terminal": terminal,
|
|
249
|
+
"tier_index": _num(tier_index) if tier_index is not None else None,
|
|
250
|
+
"high_water_roe": _num(roe) if roe is not None else None,
|
|
251
|
+
"source": "telemetry",
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _match_exit_event(trade, by_order_id, by_asset):
|
|
256
|
+
"""Find the exit event for one trade (folded from review.py::_match_exit_event): exact order id →
|
|
257
|
+
else same asset + close_time within ±EXIT_MATCH_WINDOW_MS, nearest in time. None on no match."""
|
|
258
|
+
oid = trade.get("closed_order_id")
|
|
259
|
+
if oid and str(oid) in by_order_id:
|
|
260
|
+
return by_order_id[str(oid)]
|
|
261
|
+
asset = str(trade.get("asset") or "").upper()
|
|
262
|
+
close_ms = _num(trade.get("close_time"))
|
|
263
|
+
if not asset or close_ms is None:
|
|
264
|
+
return None
|
|
265
|
+
best, best_dist = None, None
|
|
266
|
+
for ts, ev in by_asset.get(asset, []):
|
|
267
|
+
dist = abs(ts - close_ms)
|
|
268
|
+
if dist <= EXIT_MATCH_WINDOW_MS and (best_dist is None or dist < best_dist):
|
|
269
|
+
best, best_dist = ev, dist
|
|
270
|
+
return best
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _is_premature_exit(exit_reason) -> bool:
|
|
274
|
+
"""The 'shaken out too early' label for one exit (folded verbatim in spirit from review.py):
|
|
275
|
+
terminal ∈ {trailing_floor, weak_peak, max_retrace}, OR a low tier (<=1) locked with a small
|
|
276
|
+
high-water ROE (<=~5%). Fail-soft: a missing/odd exit_reason → False (never invent an early exit)."""
|
|
277
|
+
if not isinstance(exit_reason, dict):
|
|
278
|
+
return False
|
|
279
|
+
terminal = str(exit_reason.get("terminal") or "")
|
|
280
|
+
if terminal in _PREMATURE_TERMINALS:
|
|
281
|
+
return True
|
|
282
|
+
tier = _num(exit_reason.get("tier_index"))
|
|
283
|
+
roe = _num(exit_reason.get("high_water_roe"))
|
|
284
|
+
if tier is not None and tier <= _PREMATURE_TIER_MAX and roe is not None and abs(roe) <= _PREMATURE_ROE_MAX:
|
|
285
|
+
return True
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _scan_leaks(events: list[dict]) -> dict:
|
|
290
|
+
"""The leak rollup (folded from review.py::_scan_leak_and_fill_events, fee tally dropped):
|
|
291
|
+
failed orders / protection-sync gaps / risk halts, each ``{count, samples[]}``. Counts EXACT,
|
|
292
|
+
samples capped + illustrative. Fail-soft on any odd event shape."""
|
|
293
|
+
leaks = {"order_failed": {"count": 0, "samples": []},
|
|
294
|
+
"protection_gaps": {"count": 0, "samples": []},
|
|
295
|
+
"risk_halts": {"count": 0, "samples": []}}
|
|
296
|
+
for ev in events:
|
|
297
|
+
name = str(ev.get("name") or "")
|
|
298
|
+
if name == _LEAK_ORDER_FAILED:
|
|
299
|
+
cat = leaks["order_failed"]
|
|
300
|
+
cat["count"] += 1
|
|
301
|
+
if len(cat["samples"]) < _LEAK_SAMPLE_CAP:
|
|
302
|
+
cat["samples"].append({"asset": _attr(ev, "senpi.asset", "senpi.signal.asset"),
|
|
303
|
+
"reason": _attr(ev, "senpi.order.reason", "senpi.order.error_name")})
|
|
304
|
+
elif name in _LEAK_PROTECTION:
|
|
305
|
+
cat = leaks["protection_gaps"]
|
|
306
|
+
cat["count"] += 1
|
|
307
|
+
if len(cat["samples"]) < _LEAK_SAMPLE_CAP:
|
|
308
|
+
cat["samples"].append({"asset": _attr(ev, "senpi.asset", "senpi.signal.asset"), "event": name})
|
|
309
|
+
elif name == _LEAK_PAUSED:
|
|
310
|
+
cat = leaks["risk_halts"]
|
|
311
|
+
cat["count"] += 1
|
|
312
|
+
if len(cat["samples"]) < _LEAK_SAMPLE_CAP:
|
|
313
|
+
cat["samples"].append({"reason": _attr(ev, "senpi.pause.reason", "senpi.runtime.pause_reason",
|
|
314
|
+
"senpi.reason")})
|
|
315
|
+
return leaks
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# --------------------------------------------------------------------------- thesis (the graph mandate)
|
|
319
|
+
|
|
320
|
+
def _read_thesis(base_state_dir: str, strategy: str) -> Optional[str]:
|
|
321
|
+
"""The strategy's declared mandate — ``metadata.spec.thesis`` from its canonical graph.yaml
|
|
322
|
+
(composer's OWN record; NOT the runtime registry review.py read). One line, quoted verbatim next
|
|
323
|
+
to observed behaviour — never interpreted/scored. None when there is no graph or no thesis key.
|
|
324
|
+
Tolerant + never raises (a missing/unreadable/hand-authored-minimal graph simply has no thesis)."""
|
|
325
|
+
import yaml
|
|
326
|
+
|
|
327
|
+
path = canonical_graph_path(base_state_dir, strategy)
|
|
328
|
+
try:
|
|
329
|
+
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
330
|
+
except (OSError, yaml.YAMLError, ValueError):
|
|
331
|
+
return None
|
|
332
|
+
if not isinstance(doc, dict):
|
|
333
|
+
return None
|
|
334
|
+
meta = doc.get("metadata")
|
|
335
|
+
spec = meta.get("spec") if isinstance(meta, dict) else None
|
|
336
|
+
if not isinstance(spec, dict):
|
|
337
|
+
return None
|
|
338
|
+
thesis = spec.get("thesis")
|
|
339
|
+
if isinstance(thesis, str) and thesis.strip():
|
|
340
|
+
return " ".join(thesis.split()).strip()
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# --------------------------------------------------------------------------- per-strategy review
|
|
345
|
+
|
|
346
|
+
@dataclass
|
|
347
|
+
class StrategyReview:
|
|
348
|
+
"""One strategy's retrospective review. Every unavailable input is LOUD (a reason string), never
|
|
349
|
+
a confident default; the two field-proven defects are killed by construction (see module docstring)."""
|
|
350
|
+
|
|
351
|
+
strategy: str
|
|
352
|
+
wallet: Optional[str] = None
|
|
353
|
+
runtime_id: Optional[str] = None
|
|
354
|
+
thesis: Optional[str] = None
|
|
355
|
+
|
|
356
|
+
# history (backend trade history)
|
|
357
|
+
history_available: bool = True
|
|
358
|
+
history_reason: str = ""
|
|
359
|
+
closed_trade_count: int = 0
|
|
360
|
+
wins: int = 0
|
|
361
|
+
losses: int = 0
|
|
362
|
+
flats: int = 0
|
|
363
|
+
realized_pnl: float = 0.0
|
|
364
|
+
|
|
365
|
+
# exit enrichment (box event log)
|
|
366
|
+
events_available: bool = False
|
|
367
|
+
events_reason: str = ""
|
|
368
|
+
exit_distribution: dict = field(default_factory=dict) # terminal -> count (attributed exits)
|
|
369
|
+
exits_unknown: int = 0 # closed trades with no matched exit event
|
|
370
|
+
premature_exits: int = 0
|
|
371
|
+
leaks: dict = field(default_factory=dict) # {order_failed, protection_gaps, risk_halts}
|
|
372
|
+
|
|
373
|
+
recommendations: list[str] = field(default_factory=list)
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def win_rate(self) -> Optional[float]:
|
|
377
|
+
decided = self.wins + self.losses
|
|
378
|
+
return round(self.wins / decided, 4) if decided else None
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def no_closed_trades(self) -> bool:
|
|
382
|
+
"""The HONEST empty state — history read fine, but nothing closed. NOT telemetry-unavailable
|
|
383
|
+
(the defect this verb kills): a strategy waiting for its signal simply has nothing to review."""
|
|
384
|
+
return self.history_available and self.closed_trade_count == 0
|
|
385
|
+
|
|
386
|
+
def _leak_total(self) -> int:
|
|
387
|
+
return sum(int((self.leaks.get(k) or {}).get("count", 0)) for k in self.leaks)
|
|
388
|
+
|
|
389
|
+
def to_json(self) -> dict:
|
|
390
|
+
return {
|
|
391
|
+
"strategy": self.strategy,
|
|
392
|
+
"wallet": self.wallet,
|
|
393
|
+
"runtime_id": self.runtime_id,
|
|
394
|
+
"thesis": self.thesis,
|
|
395
|
+
"sources": {"history": SRC_HISTORY, "events": SRC_EVENTS},
|
|
396
|
+
"history": {
|
|
397
|
+
"available": self.history_available,
|
|
398
|
+
"reason": self.history_reason,
|
|
399
|
+
"closed_trade_count": self.closed_trade_count,
|
|
400
|
+
"wins": self.wins,
|
|
401
|
+
"losses": self.losses,
|
|
402
|
+
"flats": self.flats,
|
|
403
|
+
"win_rate": self.win_rate,
|
|
404
|
+
"realized_pnl": self.realized_pnl,
|
|
405
|
+
},
|
|
406
|
+
"events": {
|
|
407
|
+
"available": self.events_available,
|
|
408
|
+
"reason": self.events_reason,
|
|
409
|
+
"exit_distribution": dict(self.exit_distribution),
|
|
410
|
+
"exits_unknown": self.exits_unknown,
|
|
411
|
+
"premature_exits": self.premature_exits,
|
|
412
|
+
"leaks": {k: {"count": (v or {}).get("count", 0),
|
|
413
|
+
"samples": list((v or {}).get("samples", []))}
|
|
414
|
+
for k, v in self.leaks.items()},
|
|
415
|
+
},
|
|
416
|
+
"recommendations": list(self.recommendations),
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
def render(self) -> list[str]:
|
|
420
|
+
lines = [f" {self.strategy}"]
|
|
421
|
+
if self.wallet:
|
|
422
|
+
lines.append(f" wallet : {self.wallet}")
|
|
423
|
+
if self.thesis:
|
|
424
|
+
lines.append(f' thesis : "{self.thesis}" (quoted from the graph)')
|
|
425
|
+
# HISTORY (backend trade history) — LOUD when unavailable; the honest empty when nothing closed.
|
|
426
|
+
if not self.history_available:
|
|
427
|
+
lines.append(f" trades : UNAVAILABLE ({self.history_reason}) [{SRC_HISTORY}] "
|
|
428
|
+
f"— NOT assuming zero")
|
|
429
|
+
return lines
|
|
430
|
+
if self.closed_trade_count == 0:
|
|
431
|
+
lines.append(" trades : no closed trades yet — nothing to review "
|
|
432
|
+
"(often by design: waiting for its signal, not a defect)")
|
|
433
|
+
return lines
|
|
434
|
+
wr = self.win_rate
|
|
435
|
+
wr_s = f"{wr * 100:.0f}%" if wr is not None else "n/a"
|
|
436
|
+
lines.append(f" trades : {self.closed_trade_count} closed — "
|
|
437
|
+
f"{self.wins}W / {self.losses}L / {self.flats} flat, win rate {wr_s}, "
|
|
438
|
+
f"realized PnL ${self.realized_pnl:,.2f} [{SRC_HISTORY}]")
|
|
439
|
+
# EXITS + leaks (box event log) — undetermined ≠ all-clear when the ring is unavailable.
|
|
440
|
+
if not self.events_available:
|
|
441
|
+
lines.append(f" exits : UNDETERMINED ({self.events_reason}) [{SRC_EVENTS}] "
|
|
442
|
+
f"— exit quality / leaks couldn't be checked, NOT 'clean'")
|
|
443
|
+
else:
|
|
444
|
+
if self.exit_distribution:
|
|
445
|
+
mix = ", ".join(f"{k}×{v}" for k, v in sorted(self.exit_distribution.items()))
|
|
446
|
+
lines.append(f" exits : {mix}"
|
|
447
|
+
+ (f", unknown×{self.exits_unknown}" if self.exits_unknown else "")
|
|
448
|
+
+ f" [{SRC_EVENTS}]")
|
|
449
|
+
else:
|
|
450
|
+
lines.append(f" exits : no exit events matched these trades "
|
|
451
|
+
f"(unknown×{self.exits_unknown}) [{SRC_EVENTS}]")
|
|
452
|
+
if self.premature_exits:
|
|
453
|
+
lines.append(f" premature : {self.premature_exits} exit(s) look premature "
|
|
454
|
+
f"(trailing_floor / weak_peak / low-tier lock)")
|
|
455
|
+
leak_total = self._leak_total()
|
|
456
|
+
if leak_total:
|
|
457
|
+
parts = []
|
|
458
|
+
for key, label in (("order_failed", "failed order(s)"),
|
|
459
|
+
("protection_gaps", "protection-sync gap(s)"),
|
|
460
|
+
("risk_halts", "risk halt(s)")):
|
|
461
|
+
c = int((self.leaks.get(key) or {}).get("count", 0))
|
|
462
|
+
if c:
|
|
463
|
+
parts.append(f"{c} {label}")
|
|
464
|
+
lines.append(f" leaks : {', '.join(parts)} [{SRC_EVENTS}]")
|
|
465
|
+
else:
|
|
466
|
+
lines.append(f" leaks : none recorded [{SRC_EVENTS}]")
|
|
467
|
+
for r in self.recommendations:
|
|
468
|
+
lines.append(f" → {r}")
|
|
469
|
+
return lines
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _events_for_runtime(section: Optional[dict], runtime_id: Optional[str]) -> tuple[Optional[list], str]:
|
|
473
|
+
"""Resolve one strategy's event ring from the live document's ``events`` section. Returns
|
|
474
|
+
``(rows | None, reason)``: rows (possibly empty = a genuine empty ring) when available; None + a
|
|
475
|
+
LOUD reason otherwise. The reason paths are DISTINCT so 'no live channel' never masquerades as
|
|
476
|
+
'the ring is empty' (the defect-2 discipline: nothing-to-enrich ≠ telemetry-down)."""
|
|
477
|
+
if section is None:
|
|
478
|
+
return None, "no live channel — run via the rim (events are served in-process there)"
|
|
479
|
+
if section.get("ok") is False:
|
|
480
|
+
return None, section.get("reason") or "events channel unavailable"
|
|
481
|
+
if not runtime_id:
|
|
482
|
+
return None, "strategy has no installed runtime on this box — no event ring"
|
|
483
|
+
by_runtime = section.get("by_runtime")
|
|
484
|
+
if not isinstance(by_runtime, dict) or runtime_id not in by_runtime:
|
|
485
|
+
return None, f"no event ring fetched for runtime {runtime_id!r} (per-ring read failed)"
|
|
486
|
+
rows = by_runtime.get(runtime_id)
|
|
487
|
+
return ([r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []), ""
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _recommend(sr: StrategyReview) -> list[str]:
|
|
491
|
+
"""Plain, DERIVED recommendations — NO ML, NO scoring. Each is a factual routing of an observed
|
|
492
|
+
count to the lever that addresses it; when exits are undetermined the exit-quality read is called
|
|
493
|
+
out as UNCHECKED (never 'clean'), and a thesis (when present) frames the realized outcome."""
|
|
494
|
+
recs: list[str] = []
|
|
495
|
+
if not sr.history_available or sr.closed_trade_count == 0:
|
|
496
|
+
return recs
|
|
497
|
+
if sr.events_available:
|
|
498
|
+
if sr.premature_exits:
|
|
499
|
+
recs.append(f"{sr.premature_exits} exit(s) look premature — consider widening the DSL "
|
|
500
|
+
f"phase-1 retrace or retuning the arming tier so a working position isn't cut early.")
|
|
501
|
+
for key, msg in (
|
|
502
|
+
("order_failed", "failed order(s) — $ never entered; check sizing / margin / the reject reason."),
|
|
503
|
+
("protection_gaps", "protection-sync gap(s) — a stop couldn't sync/hand off (naked-leg risk); "
|
|
504
|
+
"check the exchange stop binding."),
|
|
505
|
+
("risk_halts", "risk halt(s) — trading was paused; review the halt reason vs the risk gates."),
|
|
506
|
+
):
|
|
507
|
+
c = int((sr.leaks.get(key) or {}).get("count", 0))
|
|
508
|
+
if c:
|
|
509
|
+
recs.append(f"{c} {msg}")
|
|
510
|
+
else:
|
|
511
|
+
recs.append("exit quality + leaks are UNDETERMINED (the box event ring was unavailable) — "
|
|
512
|
+
"re-run via the rim to enrich; do NOT read the absence as 'clean'.")
|
|
513
|
+
wr = sr.win_rate
|
|
514
|
+
if sr.realized_pnl < 0 and wr is not None and wr < 0.5:
|
|
515
|
+
recs.append(f"net realized LOSS (${sr.realized_pnl:,.2f}) at a {wr * 100:.0f}% win rate over "
|
|
516
|
+
f"{sr.closed_trade_count} trade(s)"
|
|
517
|
+
+ (f' — judge it against the thesis: "{sr.thesis}".' if sr.thesis
|
|
518
|
+
else " — no thesis on the graph to judge against."))
|
|
519
|
+
return recs
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _review_one(base_state_dir: str, strategy: str, runtime_id: Optional[str], wallet: Optional[str],
|
|
523
|
+
history_fn: HistoryFn, events_section: Optional[dict]) -> StrategyReview:
|
|
524
|
+
"""Review ONE strategy. Fail-open per input: a history transport failure marks history unavailable
|
|
525
|
+
(LOUD) and returns; an unavailable event ring marks exits undetermined; zero closed trades is the
|
|
526
|
+
honest empty. Never raises (the sweep must survive one bad strategy)."""
|
|
527
|
+
sr = StrategyReview(strategy=strategy, wallet=wallet, runtime_id=runtime_id,
|
|
528
|
+
thesis=_read_thesis(base_state_dir, strategy))
|
|
529
|
+
|
|
530
|
+
# HISTORY — backend trade history via the injected seam. Unavailable is LOUD, never a fake zero.
|
|
531
|
+
if not wallet:
|
|
532
|
+
sr.history_available = False
|
|
533
|
+
sr.history_reason = "no wallet bound to this strategy (not funded/installed yet)"
|
|
534
|
+
return sr
|
|
535
|
+
try:
|
|
536
|
+
rows = history_fn(wallet)
|
|
537
|
+
except HistoryUnavailable as e:
|
|
538
|
+
sr.history_available = False
|
|
539
|
+
sr.history_reason = str(e)
|
|
540
|
+
return sr
|
|
541
|
+
except Exception as e: # noqa: BLE001 — a surprise seam fault is a per-strategy datum
|
|
542
|
+
sr.history_available = False
|
|
543
|
+
sr.history_reason = f"history read raised ({e})"
|
|
544
|
+
return sr
|
|
545
|
+
|
|
546
|
+
trades = _parse_closed_trades(rows)
|
|
547
|
+
sr.closed_trade_count = len(trades)
|
|
548
|
+
sr.realized_pnl = round(sum(_num(t.get("realized_pnl")) or 0.0 for t in trades), 2)
|
|
549
|
+
for t in trades:
|
|
550
|
+
pnl = _num(t.get("realized_pnl")) or 0.0
|
|
551
|
+
if pnl > 0:
|
|
552
|
+
sr.wins += 1
|
|
553
|
+
elif pnl < 0:
|
|
554
|
+
sr.losses += 1
|
|
555
|
+
else:
|
|
556
|
+
sr.flats += 1
|
|
557
|
+
|
|
558
|
+
# EXIT enrichment — box event log ONLY (the hard rule: no events subprocess fallback). The ring
|
|
559
|
+
# is fetched even with zero closed trades so its leaks still count, but exit attribution only runs
|
|
560
|
+
# over trades. An unavailable ring => exits UNDETERMINED (never conflated with 'no closed trades').
|
|
561
|
+
rows_ev, reason = _events_for_runtime(events_section, runtime_id)
|
|
562
|
+
if rows_ev is None:
|
|
563
|
+
sr.events_available = False
|
|
564
|
+
sr.events_reason = reason
|
|
565
|
+
else:
|
|
566
|
+
sr.events_available = True
|
|
567
|
+
sr.leaks = _scan_leaks(rows_ev)
|
|
568
|
+
if trades:
|
|
569
|
+
by_order_id, by_asset = _index_exit_events(rows_ev)
|
|
570
|
+
dist: dict = {}
|
|
571
|
+
for t in trades:
|
|
572
|
+
ev = _match_exit_event(t, by_order_id, by_asset)
|
|
573
|
+
if ev is None:
|
|
574
|
+
sr.exits_unknown += 1
|
|
575
|
+
continue
|
|
576
|
+
er = _exit_reason_from_event(ev)
|
|
577
|
+
terminal = str(er.get("terminal") or "unknown") or "unknown"
|
|
578
|
+
dist[terminal] = dist.get(terminal, 0) + 1
|
|
579
|
+
if _is_premature_exit(er):
|
|
580
|
+
sr.premature_exits += 1
|
|
581
|
+
sr.exit_distribution = dist
|
|
582
|
+
|
|
583
|
+
sr.recommendations = _recommend(sr)
|
|
584
|
+
return sr
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# --------------------------------------------------------------------------- review result
|
|
588
|
+
|
|
589
|
+
@dataclass
|
|
590
|
+
class ReviewResult:
|
|
591
|
+
base_state_dir: str
|
|
592
|
+
scope: str # "all" (sweep) | "strategy" (targeted)
|
|
593
|
+
strategies: list[StrategyReview] = field(default_factory=list)
|
|
594
|
+
|
|
595
|
+
# cross-strategy realized aggregate (the realized-PnL/history view the retired portfolio skill
|
|
596
|
+
# left un-homed). Sums ONLY strategies whose history was READABLE (unreadable ones are a FLOOR,
|
|
597
|
+
# not a hole silently counted as zero).
|
|
598
|
+
def _readable(self) -> list[StrategyReview]:
|
|
599
|
+
return [s for s in self.strategies if s.history_available]
|
|
600
|
+
|
|
601
|
+
@property
|
|
602
|
+
def total_closed(self) -> int:
|
|
603
|
+
return sum(s.closed_trade_count for s in self._readable())
|
|
604
|
+
|
|
605
|
+
@property
|
|
606
|
+
def total_realized(self) -> float:
|
|
607
|
+
return round(sum(s.realized_pnl for s in self._readable()), 2)
|
|
608
|
+
|
|
609
|
+
@property
|
|
610
|
+
def total_wins(self) -> int:
|
|
611
|
+
return sum(s.wins for s in self._readable())
|
|
612
|
+
|
|
613
|
+
@property
|
|
614
|
+
def total_losses(self) -> int:
|
|
615
|
+
return sum(s.losses for s in self._readable())
|
|
616
|
+
|
|
617
|
+
@property
|
|
618
|
+
def overall_win_rate(self) -> Optional[float]:
|
|
619
|
+
decided = self.total_wins + self.total_losses
|
|
620
|
+
return round(self.total_wins / decided, 4) if decided else None
|
|
621
|
+
|
|
622
|
+
@property
|
|
623
|
+
def history_partial(self) -> bool:
|
|
624
|
+
return len(self._readable()) < len(self.strategies)
|
|
625
|
+
|
|
626
|
+
def to_json(self) -> dict:
|
|
627
|
+
return {
|
|
628
|
+
"verb": "review",
|
|
629
|
+
"scope": self.scope,
|
|
630
|
+
"state_dir": self.base_state_dir,
|
|
631
|
+
"sources": {"history": SRC_HISTORY, "events": SRC_EVENTS},
|
|
632
|
+
"strategy_count": len(self.strategies),
|
|
633
|
+
"strategies": [s.to_json() for s in self.strategies],
|
|
634
|
+
"aggregate": {
|
|
635
|
+
"total_closed_trades": self.total_closed,
|
|
636
|
+
"total_realized_pnl": self.total_realized,
|
|
637
|
+
"wins": self.total_wins,
|
|
638
|
+
"losses": self.total_losses,
|
|
639
|
+
"overall_win_rate": self.overall_win_rate,
|
|
640
|
+
"history_partial": self.history_partial,
|
|
641
|
+
},
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
def render(self) -> str:
|
|
645
|
+
lines = [
|
|
646
|
+
f"trade review (state: {self.base_state_dir})",
|
|
647
|
+
f"realized PnL + win rate quoted from {SRC_HISTORY}; exit reasons + leaks from the "
|
|
648
|
+
f"{SRC_EVENTS}. Unavailable inputs are shown as such, never assumed.",
|
|
649
|
+
"",
|
|
650
|
+
f"strategies ({len(self.strategies)}):",
|
|
651
|
+
]
|
|
652
|
+
if not self.strategies:
|
|
653
|
+
lines.append(" (none — no strategy under <state>/composer or a landed <state>/strategies "
|
|
654
|
+
"install)")
|
|
655
|
+
for s in self.strategies:
|
|
656
|
+
lines.extend(s.render())
|
|
657
|
+
# cross-strategy realized aggregate (only meaningful on the sweep with readable history).
|
|
658
|
+
if len(self.strategies) > 1:
|
|
659
|
+
lines.append("")
|
|
660
|
+
wr = self.overall_win_rate
|
|
661
|
+
wr_s = f"{wr * 100:.0f}%" if wr is not None else "n/a"
|
|
662
|
+
floor = " (FLOOR — some strategies' history was unavailable)" if self.history_partial else ""
|
|
663
|
+
lines.append(f"cross-strategy realized aggregate{floor}:")
|
|
664
|
+
lines.append(f" {self.total_closed} closed trade(s), "
|
|
665
|
+
f"{self.total_wins}W / {self.total_losses}L, overall win rate {wr_s}, "
|
|
666
|
+
f"total realized PnL ${self.total_realized:,.2f} [{SRC_HISTORY}]")
|
|
667
|
+
return "\n".join(lines) + "\n"
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# --------------------------------------------------------------------------- entry point
|
|
671
|
+
|
|
672
|
+
def _noop_state_fn(_runtime_id: Optional[str]) -> LiveState:
|
|
673
|
+
"""The SystemStateFn review hands run_status: review does NOT read live DSL/scanner state (it is a
|
|
674
|
+
retrospective realized review, not a live snapshot), so this returns an unavailable marker rather
|
|
675
|
+
than let run_status fall back to a `senpi state` cold boot. Honors the hard no-cold-boot rule."""
|
|
676
|
+
return LiveState(False, reason="not read (review does not read live state)")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def run_review(
|
|
680
|
+
base_state_dir: str,
|
|
681
|
+
target: Optional[str] = None,
|
|
682
|
+
*,
|
|
683
|
+
history_fn: HistoryFn = default_closed_history,
|
|
684
|
+
events_fn: EventsFn = default_events_section,
|
|
685
|
+
list_fn=_box.default_list_runtimes,
|
|
686
|
+
) -> ReviewResult:
|
|
687
|
+
"""Retrospective trade-quality review. Bare (``target`` None) sweeps every accountable strategy
|
|
688
|
+
(canonical + landed-legacy, via ``portfolio.enumerate_strategies``) + the cross-strategy realized
|
|
689
|
+
aggregate; a named target reviews that ONE strategy. Each strategy's on-box runtime id + wallet
|
|
690
|
+
are derived by the SINGLE PRODUCER (``run_status`` + ``portfolio._effective_install``), reusing the
|
|
691
|
+
rim-served ``runtime_list`` (no cold boot) and the no-op state_fn (no live-state read). Closed
|
|
692
|
+
history comes from ``history_fn`` (in-process MCP seam); exit/leak enrichment from ``events_fn``
|
|
693
|
+
(the live document's ``events`` section — NEVER a subprocess). Fail-open per strategy."""
|
|
694
|
+
state_dir = Path(base_state_dir)
|
|
695
|
+
result = ReviewResult(base_state_dir=str(state_dir),
|
|
696
|
+
scope="strategy" if target else "all")
|
|
697
|
+
|
|
698
|
+
if target:
|
|
699
|
+
names = [target]
|
|
700
|
+
else:
|
|
701
|
+
names = enumerate_strategies(state_dir)
|
|
702
|
+
|
|
703
|
+
# ONE runtime-list read shared across the sweep (served from the live doc; empty-on-error).
|
|
704
|
+
try:
|
|
705
|
+
runtimes = list_fn()
|
|
706
|
+
except Exception: # noqa: BLE001 — a listing fault degrades to no runtime ids, not a crash
|
|
707
|
+
runtimes = []
|
|
708
|
+
shared_list = lambda: runtimes # noqa: E731
|
|
709
|
+
|
|
710
|
+
# the events section is read ONCE (the whole live document is served once per invocation).
|
|
711
|
+
try:
|
|
712
|
+
events_section = events_fn()
|
|
713
|
+
except Exception: # noqa: BLE001 — an events-read fault degrades to unavailable, not a crash
|
|
714
|
+
events_section = None
|
|
715
|
+
|
|
716
|
+
for strategy in names:
|
|
717
|
+
st = run_status(strategy, state_dir=str(state_dir),
|
|
718
|
+
list_fn=shared_list, state_fn=_noop_state_fn)
|
|
719
|
+
install = _effective_install(st)
|
|
720
|
+
runtime_id = install.runtime_id if install is not None else None
|
|
721
|
+
wallet = _resolve_wallet(state_dir, strategy, install)
|
|
722
|
+
result.strategies.append(
|
|
723
|
+
_review_one(str(state_dir), strategy, runtime_id, wallet, history_fn, events_section))
|
|
724
|
+
return result
|