@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.
- package/dist/cli/composer-commands.d.ts.map +1 -1
- package/dist/cli/composer-commands.js +37 -2
- package/dist/cli/composer-commands.js.map +1 -1
- package/dist/composer/cli.py +115 -19
- package/dist/composer/sim/timeline.py +47 -3
- package/dist/composer/validator/errors.py +2 -0
- package/dist/composer/verbs/__init__.py +3 -0
- package/dist/composer/verbs/_target.py +138 -0
- package/dist/composer/verbs/close.py +3 -1
- package/dist/composer/verbs/fund.py +78 -16
- package/dist/composer/verbs/install.py +4 -2
- package/dist/composer/verbs/ledger.py +453 -0
- package/dist/composer/verbs/review.py +280 -1
- package/dist/composer/verbs/status.py +26 -5
- package/dist/composer/verbs/update.py +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/no-reply-scoping-overlay.d.ts +75 -0
- package/dist/runtime/no-reply-scoping-overlay.d.ts.map +1 -0
- package/dist/runtime/no-reply-scoping-overlay.js +93 -0
- package/dist/runtime/no-reply-scoping-overlay.js.map +1 -0
- package/package.json +1 -1
|
@@ -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
|