@senpi/runtime 3.0.9-dev.feat-strategy-compos.20260722213012 → 3.0.9-dev.feat-strategy-compos.20260723050017

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.
@@ -0,0 +1,453 @@
1
+ """ledger — the account MONEY-FLOW view for ``composer ledger`` (slice K6).
2
+
3
+ ``composer status`` (bare, J2) is the lifecycle/PROTECTION portfolio view; ``composer review`` (J8)
4
+ is trade-QUALITY analysis. "Walk me through every dollar" / "how much have I funded vs withdrawn" is
5
+ a THIRD, distinct question — the account MONEY-FLOW ledger — and it gets its own verb so neither of
6
+ those surfaces has to answer a question it was not built for.
7
+
8
+ WHY A DEDICATED VERB (not a status section): the confabulation this slice kills (dev-test #7, F4) was
9
+ the model HAND-DERIVING a per-dollar PnL table because NO money-flow render existed — so it invented
10
+ rows that corresponded to no tool output. The fix is a single-producer render the skill QUOTES
11
+ VERBATIM. status is already dense (stage/wallet/runtime/protection + stranded + manual per strategy);
12
+ bolting a money table onto it blurs both. review homes realized-PnL but on the trade-quality axis, not
13
+ capital in/out. So ledger is its own thin verb.
14
+
15
+ It composes ONLY existing fields — nothing is derived that the backend does not already carry:
16
+
17
+ * per-strategy ``totalFunded`` / ``totalWithdrawn`` / ``netFunded`` — from MCP ``strategy_list``
18
+ (source named ``backend strategy_list``). ``net`` prefers the explicit ``netFunded`` field; when
19
+ the backend carries none it is COMPUTED as ``funded − withdrawn`` (only when BOTH are present) and
20
+ labeled computed. A field the backend did not return renders "unavailable (reason)" — NEVER zero.
21
+ * per-strategy realized PnL — summed from the backend closed-trade ledger via
22
+ ``discovery_get_trader_history`` (the SAME seam ``composer review`` reads; source named
23
+ ``backend trade history``). A per-wallet history transport failure degrades THAT strategy's PnL
24
+ cell only, never the whole ledger.
25
+ * the free embedded-wallet balance — from MCP ``account_get_portfolio`` (source named
26
+ ``backend account_get_portfolio``), reusing ``fund``'s exact free-USDC definition (Hyperliquid +
27
+ EVM), so ledger and the fund preflight agree on what "free balance" means.
28
+
29
+ FAIL-LOUD honesty (settled doctrine): every unavailable input renders "unavailable (reason)"; every
30
+ quoted figure NAMES its source; a missing MCP credential is the teaching CMP223 (like ``fund``/
31
+ ``close``); a ``strategy_list`` transport failure is CMP260 (the account-wide enumeration cannot be
32
+ built) while a single strategy's history failure degrades only that row.
33
+
34
+ SCOPE is ACCOUNT-LEVEL: ledger enumerates ALL live strategies from ``strategy_list`` (money flow is an
35
+ account question, not a composer-inventory one) and, when a state root is available, JOINS the
36
+ composer-known strategy NAME onto a row by wallet. The state root is therefore OPTIONAL — a bare run
37
+ labels rows by their backend ``strategyName``.
38
+
39
+ The single MCP touch point is the injectable ``mcp_fn`` seam (see ``_box``), so the whole verb is
40
+ tested with no network — a stubbed ``mcp_fn`` dispatches on the tool name.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+ from typing import Optional
49
+
50
+ from composer.validator.errors import Location, TeachingError
51
+
52
+ from . import _box
53
+ from ._box import LIVE_STATUSES, McpCredsMissing, McpError, McpFn
54
+ from ._layout import resolve_wallet_json
55
+ from .close import (
56
+ _dig,
57
+ _find_list,
58
+ _strat_id,
59
+ _strat_name,
60
+ _strat_status,
61
+ _strat_wallet,
62
+ _strategy_obj,
63
+ )
64
+ from .fund import _available_usd, _rec_wallet
65
+ from .portfolio import enumerate_strategies
66
+ from .review import HistoryUnavailable, _parse_closed_trades, default_closed_history
67
+
68
+ # The named sources every figure is attributed to (printed so an operator always knows the producer).
69
+ SRC_FUNDING = "backend strategy_list" # totalFunded / totalWithdrawn / netFunded per strategy
70
+ SRC_HISTORY = "backend trade history" # discovery_get_trader_history (onchain closed ledger)
71
+ SRC_BALANCE = "backend account_get_portfolio" # free embedded-wallet USDC (Hyperliquid + EVM)
72
+
73
+
74
+ # --------------------------------------------------------------------------- helpers
75
+
76
+ def _num(v) -> Optional[float]:
77
+ """A tolerant number read (the gateway stringifies some decimals)."""
78
+ if isinstance(v, bool):
79
+ return None
80
+ if isinstance(v, (int, float)):
81
+ return float(v)
82
+ if isinstance(v, str):
83
+ try:
84
+ return float(v.strip())
85
+ except ValueError:
86
+ return None
87
+ return None
88
+
89
+
90
+ def _money(v: float) -> str:
91
+ return f"-${abs(v):,.2f}" if v < 0 else f"${v:,.2f}"
92
+
93
+
94
+ def _wallet_label(w: str) -> str:
95
+ """The truncated 0x… form (mirrors close.py's target/wallet labels); full address rides in
96
+ --json. A short/empty wallet renders plainly rather than a misleading ellipsis."""
97
+ return (w[:14] + "…") if w and len(w) > 15 else (w or "unknown")
98
+
99
+
100
+ # --------------------------------------------------------------------------- one money figure
101
+
102
+ @dataclass
103
+ class Cell:
104
+ """One money figure with its availability. FAIL-LOUD: an unavailable figure carries the REASON
105
+ and is NEVER silently zeroed; a computed value (net = funded − withdrawn) is flagged."""
106
+
107
+ available: bool
108
+ value: Optional[float] = None
109
+ reason: str = ""
110
+ computed: bool = False
111
+
112
+ @classmethod
113
+ def ok(cls, value: float, *, computed: bool = False) -> "Cell":
114
+ return cls(True, float(value), "", computed)
115
+
116
+ @classmethod
117
+ def na(cls, reason: str) -> "Cell":
118
+ return cls(False, None, reason, False)
119
+
120
+ def to_json(self) -> dict:
121
+ out: dict = {"available": self.available, "value": self.value, "reason": self.reason}
122
+ if self.computed:
123
+ out["computed"] = True
124
+ return out
125
+
126
+ def cell_str(self, *, mark_computed: bool = False) -> str:
127
+ if not self.available:
128
+ return "unavailable"
129
+ s = _money(self.value if self.value is not None else 0.0)
130
+ return s + "*" if (mark_computed and self.computed) else s
131
+
132
+
133
+ def _money_cell(obj: dict, label: str, *keys: str) -> Cell:
134
+ """Read a money field from a ``strategy_list`` record. An ABSENT key is LOUD-unavailable (never
135
+ 0); a present-but-non-numeric value is LOUD-unavailable naming the offending value."""
136
+ v = _dig(obj, *keys)
137
+ if v is None:
138
+ return Cell.na(f"{SRC_FUNDING} carried no {label} field for this strategy")
139
+ n = _num(v)
140
+ if n is None:
141
+ return Cell.na(f"{SRC_FUNDING} carried {label} as a non-number ({v!r})")
142
+ return Cell.ok(n)
143
+
144
+
145
+ def _derive_net(funded: Cell, withdrawn: Cell, obj: dict) -> Cell:
146
+ """``net`` prefers the explicit backend ``netFunded`` field; falls back to a COMPUTED
147
+ ``funded − withdrawn`` only when BOTH are present; otherwise LOUD-unavailable naming which input
148
+ was missing (never a guessed 0)."""
149
+ v = _dig(obj, "netFunded", "net_funded", "netDeposited")
150
+ if v is not None:
151
+ n = _num(v)
152
+ if n is not None:
153
+ return Cell.ok(n)
154
+ if funded.available and withdrawn.available:
155
+ return Cell.ok(funded.value - withdrawn.value, computed=True)
156
+ missing = [name for name, cell in (("funded", funded), ("withdrawn", withdrawn))
157
+ if not cell.available]
158
+ return Cell.na(f"{SRC_FUNDING} carried no netFunded field and net cannot be computed "
159
+ f"({' & '.join(missing)} unavailable)")
160
+
161
+
162
+ def _realized_cell(wallet: str, mcp_fn: McpFn) -> Cell:
163
+ """Realized PnL for one wallet = sum of the backend closed-trade ledger's realized PnL (via
164
+ ``discovery_get_trader_history`` — review.py's exact seam + parse). A transport/credential
165
+ failure for THIS wallet degrades to LOUD-unavailable (the sweep survives), never a fake 0."""
166
+ if not wallet:
167
+ return Cell.na("no wallet on this strategy record — cannot read trade history")
168
+ try:
169
+ rows = default_closed_history(wallet, mcp_fn)
170
+ except HistoryUnavailable as e:
171
+ return Cell.na(str(e))
172
+ except Exception as e: # noqa: BLE001 — a surprise seam fault is a per-strategy datum, not a crash
173
+ return Cell.na(f"trade history read raised ({e})")
174
+ trades = _parse_closed_trades(rows)
175
+ total = round(sum(_num(t.get("realized_pnl")) or 0.0 for t in trades), 2)
176
+ return Cell.ok(total)
177
+
178
+
179
+ def _balance_cell(mcp_fn: McpFn) -> Cell:
180
+ """The free embedded-wallet balance via ``account_get_portfolio`` — reusing ``fund._available_usd``
181
+ (free Hyperliquid + EVM USDC) so ledger and the fund preflight define "free balance" identically.
182
+ A creds/transport/unreadable-shape failure degrades LOUD, never a fabricated 0."""
183
+ try:
184
+ raw = mcp_fn("account_get_portfolio", timeout=_box._SUBMIT_TIMEOUT)
185
+ except McpCredsMissing as e:
186
+ return Cell.na(f"MCP credentials missing ({e})")
187
+ except McpError as e:
188
+ return Cell.na(f"MCP error reading account portfolio ({e})")
189
+ except Exception as e: # noqa: BLE001
190
+ return Cell.na(f"could not read account portfolio ({e})")
191
+ v = _available_usd(raw)
192
+ if v is None:
193
+ return Cell.na("account_get_portfolio returned no readable free-USDC balance")
194
+ return Cell.ok(v)
195
+
196
+
197
+ def _composer_names_by_wallet(base_state_dir: str) -> dict:
198
+ """Map lower-cased wallet address → composer strategy NAME for every accountable strategy under
199
+ the state root (best-effort; a malformed/absent record is skipped). Used to LABEL a backend row
200
+ with its composer name when we know it — money flow stays account-level, names are a courtesy."""
201
+ out: dict = {}
202
+ try:
203
+ names = enumerate_strategies(Path(base_state_dir))
204
+ except Exception: # noqa: BLE001 — an unreadable state root just means no name join
205
+ return out
206
+ for strat in names:
207
+ wj_path, _layout = resolve_wallet_json(base_state_dir, strat)
208
+ if wj_path is None:
209
+ continue
210
+ try:
211
+ rec = json.loads(wj_path.read_text(encoding="utf-8"))
212
+ except (OSError, ValueError):
213
+ continue
214
+ w = _rec_wallet(rec)
215
+ if w:
216
+ out[w.lower()] = strat
217
+ return out
218
+
219
+
220
+ # --------------------------------------------------------------------------- per-strategy row
221
+
222
+ @dataclass
223
+ class LedgerStrategy:
224
+ name: str # composer-known name if joined, else the backend strategyName / wallet
225
+ backend_name: str
226
+ wallet: str
227
+ strategy_id: Optional[str]
228
+ status: str
229
+ funded: Cell
230
+ withdrawn: Cell
231
+ net: Cell
232
+ realized: Cell
233
+
234
+ def to_json(self) -> dict:
235
+ return {
236
+ "strategy": self.name,
237
+ "backend_name": self.backend_name,
238
+ "wallet": self.wallet,
239
+ "strategy_id": self.strategy_id,
240
+ "status": self.status,
241
+ "funded": self.funded.to_json(),
242
+ "withdrawn": self.withdrawn.to_json(),
243
+ "net": self.net.to_json(),
244
+ "realized_pnl": self.realized.to_json(),
245
+ }
246
+
247
+
248
+ # --------------------------------------------------------------------------- result
249
+
250
+ @dataclass
251
+ class LedgerResult:
252
+ base_state_dir: Optional[str]
253
+ strategies: list = field(default_factory=list)
254
+ balance: Cell = field(default_factory=lambda: Cell.na("not read"))
255
+ error: Optional[TeachingError] = None
256
+
257
+ @property
258
+ def ok(self) -> bool:
259
+ return self.error is None
260
+
261
+ def _total(self, attr: str) -> tuple[float, bool]:
262
+ """Sum the AVAILABLE values for one column; the total is a FLOOR (partial=True) when any
263
+ row's value was unavailable (never silently counted as 0)."""
264
+ cells = [getattr(s, attr) for s in self.strategies]
265
+ vals = [c.value for c in cells if c.available and c.value is not None]
266
+ partial = any(not c.available for c in cells)
267
+ return round(sum(vals), 2), partial
268
+
269
+ def to_json(self) -> dict:
270
+ fv, fp = self._total("funded")
271
+ wv, wp = self._total("withdrawn")
272
+ nv, npart = self._total("net")
273
+ rv, rp = self._total("realized")
274
+ return {
275
+ "verb": "ledger",
276
+ "state_dir": self.base_state_dir,
277
+ "sources": {"funding": SRC_FUNDING, "realized_pnl": SRC_HISTORY, "balance": SRC_BALANCE},
278
+ "ok": self.ok,
279
+ "strategy_count": len(self.strategies),
280
+ "strategies": [s.to_json() for s in self.strategies],
281
+ "embedded_wallet_balance": self.balance.to_json(),
282
+ "totals": {
283
+ "funded": {"value": fv, "partial": fp},
284
+ "withdrawn": {"value": wv, "partial": wp},
285
+ "net": {"value": nv, "partial": npart},
286
+ "realized_pnl": {"value": rv, "partial": rp},
287
+ },
288
+ "error_count": 1 if self.error else 0,
289
+ "errors": [self.error.to_dict()] if self.error else [],
290
+ }
291
+
292
+ def render(self) -> str:
293
+ if self.error is not None:
294
+ return self.error.render() + "\n"
295
+
296
+ state_note = (f" (state: {self.base_state_dir})" if self.base_state_dir
297
+ else " (no state root — rows labeled by backend name)")
298
+ lines = [
299
+ f"ledger — account money-flow{state_note}",
300
+ f"funded / withdrawn / net: {SRC_FUNDING} · realized PnL: {SRC_HISTORY} · "
301
+ f"embedded-wallet balance: {SRC_BALANCE}.",
302
+ "every figure is quoted from a NAMED backend source; unavailable inputs read "
303
+ "\"unavailable\" (see notes), never assumed zero.",
304
+ "",
305
+ f"strategies ({len(self.strategies)}):",
306
+ ]
307
+
308
+ header = ("strategy", "wallet", "funded", "withdrawn", "net", "realized PnL")
309
+ if not self.strategies:
310
+ lines.append(" (none — no live strategies on this account per backend strategy_list)")
311
+ else:
312
+ rows = [(
313
+ s.name,
314
+ _wallet_label(s.wallet),
315
+ s.funded.cell_str(),
316
+ s.withdrawn.cell_str(),
317
+ s.net.cell_str(mark_computed=True),
318
+ s.realized.cell_str(),
319
+ ) for s in self.strategies]
320
+
321
+ fv, fp = self._total("funded")
322
+ wv, wp = self._total("withdrawn")
323
+ nv, npart = self._total("net")
324
+ rv, rp = self._total("realized")
325
+ total_row = ("TOTAL", "", _money(fv), _money(wv), _money(nv), _money(rv))
326
+
327
+ all_rows = [header] + rows + [total_row]
328
+ widths = [max(len(r[i]) for r in all_rows) for i in range(6)]
329
+
330
+ def _fmt(r: tuple) -> str:
331
+ cells = [
332
+ (r[i].ljust(widths[i]) if i < 2 else r[i].rjust(widths[i]))
333
+ for i in range(6)
334
+ ]
335
+ return " " + " ".join(cells).rstrip()
336
+
337
+ sep = " " + "-" * (sum(widths) + 2 * (len(widths) - 1))
338
+ lines.append(_fmt(header))
339
+ lines.append(sep)
340
+ lines.extend(_fmt(r) for r in rows)
341
+ lines.append(sep)
342
+ lines.append(_fmt(total_row))
343
+
344
+ # embedded-wallet balance — the free capital NOT deployed into any strategy.
345
+ lines.append("")
346
+ if self.balance.available:
347
+ lines.append(f"embedded-wallet balance (free, not deployed): "
348
+ f"{_money(self.balance.value)} [{SRC_BALANCE}]")
349
+ else:
350
+ lines.append(f"embedded-wallet balance: unavailable ({self.balance.reason}) "
351
+ f"[{SRC_BALANCE}]")
352
+
353
+ # NOTES — every unavailable cell named with its reason + source; computed-net legend; FLOOR
354
+ # note when a total omitted an unavailable row. NOTHING is silently dropped.
355
+ notes: list[str] = []
356
+ for s in self.strategies:
357
+ for label, cell in (("funded", s.funded), ("withdrawn", s.withdrawn),
358
+ ("net", s.net), ("realized PnL", s.realized)):
359
+ if not cell.available:
360
+ notes.append(f" - {s.name} {label}: unavailable — {cell.reason}")
361
+ if any(s.net.computed for s in self.strategies):
362
+ notes.append(" * net computed as funded − withdrawn (backend carried no netFunded field)")
363
+ if self.strategies:
364
+ partial_cols = [name for name, (_v, p) in (
365
+ ("funded", self._total("funded")), ("withdrawn", self._total("withdrawn")),
366
+ ("net", self._total("net")), ("realized PnL", self._total("realized")),
367
+ ) if p]
368
+ if partial_cols:
369
+ notes.append(f" † TOTAL is a FLOOR — some rows' {', '.join(partial_cols)} were "
370
+ f"unavailable and are excluded from the sum (never counted as zero).")
371
+ if notes:
372
+ lines.append("")
373
+ lines.append("notes:")
374
+ lines.extend(notes)
375
+
376
+ return "\n".join(lines) + "\n"
377
+
378
+
379
+ # --------------------------------------------------------------------------- teaching errors
380
+
381
+ def _creds_error(exc: Exception) -> TeachingError:
382
+ return TeachingError(
383
+ "CMP223", f"MCP credentials missing: {exc}",
384
+ "Set SENPI_AUTH_TOKEN or SENPI_API_KEY (and optionally SENPI_MCP_URL) — ledger reads the "
385
+ "money flow from the MCP server (strategy_list funding fields, account_get_portfolio "
386
+ "balance, and per-wallet trade history).",
387
+ Location(),
388
+ )
389
+
390
+
391
+ def _list_error(exc: Exception) -> TeachingError:
392
+ return TeachingError(
393
+ "CMP260", f"MCP `strategy_list` failed: {str(exc)[:200]} — the money-flow ledger cannot be "
394
+ f"built without it.",
395
+ "strategy_list is the source of every funded / withdrawn / net figure and the row set "
396
+ "itself. Check MCP creds/connectivity and re-run `composer ledger`. (A single strategy's "
397
+ "trade-history read failing degrades only that row; this is the account-wide enumeration "
398
+ "failing.)",
399
+ Location(),
400
+ )
401
+
402
+
403
+ # --------------------------------------------------------------------------- entry point
404
+
405
+ def run_ledger(
406
+ base_state_dir: Optional[str] = None,
407
+ *,
408
+ mcp_fn: McpFn = _box.default_mcp_call,
409
+ ) -> LedgerResult:
410
+ """The account money-flow ledger (see module docstring). Enumerates ALL live strategies from
411
+ ``strategy_list``, joins composer-known names by wallet when a state root is given, reads each
412
+ wallet's realized PnL from the backend trade history, and appends the free embedded-wallet
413
+ balance. Every input is fail-loud; a per-wallet history failure degrades that row only."""
414
+ result = LedgerResult(base_state_dir=str(base_state_dir) if base_state_dir else None)
415
+
416
+ # (1) strategy_list — the funding-ledger source AND the row set. Creds/transport are LOUD: the
417
+ # account-wide money flow cannot be enumerated without it (per-row history failures are separate).
418
+ try:
419
+ raw = mcp_fn("strategy_list", status=LIVE_STATUSES)
420
+ except McpCredsMissing as e:
421
+ result.error = _creds_error(e)
422
+ return result
423
+ except McpError as e:
424
+ result.error = _list_error(e)
425
+ return result
426
+ except Exception as e: # noqa: BLE001 — a surprise transport fault is still the enumeration failing
427
+ result.error = _list_error(e)
428
+ return result
429
+
430
+ strategies_raw = _find_list(raw, "strategies")
431
+ names_by_wallet = _composer_names_by_wallet(base_state_dir) if base_state_dir else {}
432
+
433
+ for s in strategies_raw:
434
+ obj = _strategy_obj(s)
435
+ wallet = str(_strat_wallet(s) or "")
436
+ backend_name = str(_strat_name(s) or "")
437
+ composer_name = names_by_wallet.get(wallet.lower(), "")
438
+ name = composer_name or backend_name or (_wallet_label(wallet) if wallet else "unknown")
439
+
440
+ funded = _money_cell(obj, "totalFunded", "totalFunded", "total_funded", "funding")
441
+ withdrawn = _money_cell(obj, "totalWithdrawn", "totalWithdrawn", "total_withdrawn")
442
+ net = _derive_net(funded, withdrawn, obj)
443
+ realized = _realized_cell(wallet, mcp_fn)
444
+
445
+ result.strategies.append(LedgerStrategy(
446
+ name=name, backend_name=backend_name, wallet=wallet,
447
+ strategy_id=_strat_id(s), status=str(_strat_status(s) or ""),
448
+ funded=funded, withdrawn=withdrawn, net=net, realized=realized,
449
+ ))
450
+
451
+ # (2) the free embedded-wallet balance — LOUD-degrades, never a hard stop on the whole ledger.
452
+ result.balance = _balance_cell(mcp_fn)
453
+ return result