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