@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716233548
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 +37 -0
- package/dist/cli/composer-commands.d.ts.map +1 -0
- package/dist/cli/composer-commands.js +396 -0
- package/dist/cli/composer-commands.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +2 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/validate-runtime-config.mjs +190 -0
- package/dist/composer/AUTHORING.md +435 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +59 -0
- package/dist/composer/authoring/answers.py +297 -0
- package/dist/composer/authoring/archetypes/__init__.py +26 -0
- package/dist/composer/authoring/archetypes/_common.py +410 -0
- package/dist/composer/authoring/archetypes/breakout.py +134 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +76 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
- package/dist/composer/authoring/builder.py +191 -0
- package/dist/composer/authoring/emit.py +264 -0
- package/dist/composer/authoring/gaps.py +115 -0
- package/dist/composer/authoring/generate.py +131 -0
- package/dist/composer/authoring/presets.py +168 -0
- package/dist/composer/authoring/runtime_emit.py +266 -0
- package/dist/composer/authoring/strategy_manifest.py +94 -0
- package/dist/composer/cli.py +487 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +739 -0
- package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
- package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
- package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
- package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
- package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
- package/dist/composer/generated/README.md +22 -0
- package/dist/composer/generated/catalog.md +118 -0
- package/dist/composer/generated/describe.md +1168 -0
- package/dist/composer/generated/graph.schema.json +1946 -0
- package/dist/composer/primitives/__init__.py +320 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +363 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +326 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +979 -0
- package/dist/composer/primitives/stateful.py +254 -0
- package/dist/composer/pure_fn/__init__.py +34 -0
- package/dist/composer/pure_fn/checks.py +39 -0
- package/dist/composer/pure_fn/models.py +177 -0
- package/dist/composer/pure_fn/purity.py +311 -0
- package/dist/composer/pure_fn/runtests.py +155 -0
- package/dist/composer/registry/__init__.py +40 -0
- package/dist/composer/registry/generate.py +425 -0
- package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
- package/dist/composer/registry/mcp_closure.py +311 -0
- package/dist/composer/registry/models.py +417 -0
- package/dist/composer/registry/nodes.py +1297 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/scaffold_path.py +118 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +34 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +227 -0
- package/dist/composer/validator/graph_checks.py +443 -0
- package/dist/composer/validator/loader.py +110 -0
- package/dist/composer/validator/schema_checks.py +147 -0
- package/dist/composer/validator/validate.py +86 -0
- package/dist/composer/vendoring.py +103 -0
- package/dist/composer/verbs/__init__.py +49 -0
- package/dist/composer/verbs/_box.py +308 -0
- package/dist/composer/verbs/check.py +254 -0
- package/dist/composer/verbs/close.py +494 -0
- package/dist/composer/verbs/deploy.py +279 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/fund.py +498 -0
- package/dist/composer/verbs/install.py +586 -0
- package/dist/composer/verbs/runtime_validate.py +422 -0
- package/dist/composer/verbs/smoke.py +134 -0
- package/dist/composer/verbs/status.py +383 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -9
- package/dist/index.js.map +1 -1
- package/dist/runtime/load-runtime-config.d.ts +17 -0
- package/dist/runtime/load-runtime-config.d.ts.map +1 -1
- package/dist/runtime/load-runtime-config.js +2 -2
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime-api/config.d.ts +2 -2
- package/dist/runtime-api/config.js +4 -4
- package/dist/runtime-api/config.js.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
- package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.js +5 -14
- package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +2 -8
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/skills-manager/manifest.d.ts.map +1 -1
- package/dist/skills-manager/manifest.js +1 -3
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -3
- package/package.json +4 -2
- package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
- package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
- package/dist/runtime-api/intake/ingest-mutex.js +0 -18
- package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""fund — create + fund a strategy's wallet as a first-class composer step (slice B3).
|
|
2
|
+
|
|
3
|
+
Until B3 the composer owned the whole lifecycle EXCEPT funding: the skill told the agent to
|
|
4
|
+
"create/verify the wallet conversationally via MCP, then `composer install --wallet 0x…`". This
|
|
5
|
+
verb pulls that step INSIDE the flow, without repeating the legacy ops mistake that motivated it:
|
|
6
|
+
``senpi-strategy-ops/deploy.py`` DERIVED each instance's budget from ``funding_share`` and capped
|
|
7
|
+
the total against a live balance minus per-wallet fee buffers — that derivation misfired on a real
|
|
8
|
+
dev box (it split/rescaled a budget, bridged in pieces, and told a user with sufficient balance to
|
|
9
|
+
top up). So we port the ops SEMANTICS (which MCP tools, the $100 floor, record-before-poll, poll to
|
|
10
|
+
ACTIVE) but NEVER the budget-derivation math:
|
|
11
|
+
|
|
12
|
+
composer fund <target> --budget <usd>
|
|
13
|
+
|
|
14
|
+
``--budget`` is REQUIRED and EXPLICIT — the exact USD to fund THIS strategy's single wallet with,
|
|
15
|
+
never derived and never split. It is validated ≥ the platform minimum ($100, CMP230).
|
|
16
|
+
|
|
17
|
+
Steps — resumable-by-artifact and idempotent (re-running IS the poll):
|
|
18
|
+
|
|
19
|
+
(a) If ``<state>/strategies/<strategy>/wallet.json`` exists, the wallet is already minted. It
|
|
20
|
+
lives at the STRATEGY level (ABOVE the ``<hash8>`` install dirs) because a strategy's funded
|
|
21
|
+
wallet is its instance identity ACROSS content versions — re-authoring/re-deploying changes
|
|
22
|
+
the hash dir, not the wallet. Verify it via MCP ``strategy_list``:
|
|
23
|
+
ACTIVE → FUNDED (no-op; print the wallet for `composer install`).
|
|
24
|
+
still initializing → PENDING (wait-and-re-run guidance; NOT a failure).
|
|
25
|
+
closed / not found → CMP231 (stale wallet.json — how to proceed).
|
|
26
|
+
|
|
27
|
+
(b) No wallet.json → preflight the TOTAL visible balance via MCP ``account_get_portfolio``
|
|
28
|
+
(free Hyperliquid USDC + free EVM USDC, summed the way the backend funds a create). If the
|
|
29
|
+
visible total is short of ``--budget`` → CMP232 stating the current visible total, the exact
|
|
30
|
+
shortfall, the known bridged-funds-visibility LAG (funds moved HL→EVM can take ~a minute to
|
|
31
|
+
be seen — wait + re-run), and the top-up options. The composer NEVER bridges itself.
|
|
32
|
+
|
|
33
|
+
(c) Sufficient → call MCP ``strategy_create_custom_strategy`` (a MONEY MUTATION — the result and
|
|
34
|
+
its ``pre_action`` line state exactly what is being created, budget, and funding source; the
|
|
35
|
+
user's confirmation is conversational per the skill, there is no ``--yes`` on this
|
|
36
|
+
agent-driven CLI). Attribution mirrors ops: ``skillName`` = the strategy name, so
|
|
37
|
+
``composer close``'s legacy discovery (`_strat_skill == target`) also matches wallets we
|
|
38
|
+
mint. The strategyId is recorded into wallet.json BEFORE any polling (an interrupted run
|
|
39
|
+
resumes at (a) instead of double-funding), then strategy_list is polled BRIEFLY for ACTIVE
|
|
40
|
+
(bounded — a few attempts; still pending → PENDING + re-run, never a failure).
|
|
41
|
+
|
|
42
|
+
Every MCP touch is behind the injectable ``McpFn`` seam (see ``_box``), so the whole verb is
|
|
43
|
+
tested with no network. Balance quirks are TEACHING (surfaced), never coded around silently.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import json
|
|
49
|
+
import re
|
|
50
|
+
import time
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Callable, Optional
|
|
54
|
+
|
|
55
|
+
from composer.validator.errors import Location, TeachingError
|
|
56
|
+
|
|
57
|
+
from . import _box
|
|
58
|
+
from ._box import (
|
|
59
|
+
CLOSED_STATUSES,
|
|
60
|
+
LIVE_STATUSES,
|
|
61
|
+
McpCredsMissing,
|
|
62
|
+
McpError,
|
|
63
|
+
McpFn,
|
|
64
|
+
wallet_match,
|
|
65
|
+
)
|
|
66
|
+
from .close import _dig, _find_list, _strat_id, _strat_status, _strat_wallet
|
|
67
|
+
from .install import _resolve_staged
|
|
68
|
+
|
|
69
|
+
FUNDED = "FUNDED"
|
|
70
|
+
PENDING = "PENDING"
|
|
71
|
+
REFUSED = "REFUSED"
|
|
72
|
+
FAILED = "FAILED"
|
|
73
|
+
|
|
74
|
+
WALLET_JSON = "wallet.json"
|
|
75
|
+
|
|
76
|
+
# The platform minimum per strategy wallet — mirrors senpi-strategy-ops deploy.py MIN_WALLET
|
|
77
|
+
# (deploy.py:44). A budget below this is rejected up front (CMP230) rather than failing at the
|
|
78
|
+
# MCP create with a bare backend error.
|
|
79
|
+
MIN_BUDGET = 100.0
|
|
80
|
+
|
|
81
|
+
# Name-conflict / name-format MCP error codes the create tolerates by retrying WITHOUT a custom
|
|
82
|
+
# strategyName — ported from deploy.py:228 (naming is best-effort, never blocks the fund).
|
|
83
|
+
_NAME_ERR_CODES = ("SERR055", "SERR056", "SERR058")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --------------------------------------------------------------------------- balance preflight
|
|
87
|
+
|
|
88
|
+
def _num(v) -> Optional[float]:
|
|
89
|
+
"""A tolerant number read (the gateway stringifies some decimals)."""
|
|
90
|
+
if isinstance(v, bool):
|
|
91
|
+
return None
|
|
92
|
+
if isinstance(v, (int, float)):
|
|
93
|
+
return float(v)
|
|
94
|
+
if isinstance(v, str):
|
|
95
|
+
try:
|
|
96
|
+
return float(v.strip())
|
|
97
|
+
except ValueError:
|
|
98
|
+
return None
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _portfolio_obj(res) -> dict:
|
|
103
|
+
"""Unwrap the portfolio mapping from an ``account_get_portfolio`` response (data.portfolio /
|
|
104
|
+
portfolio / the dict itself), degrading to {} rather than throwing on a surprise shape."""
|
|
105
|
+
if not isinstance(res, dict):
|
|
106
|
+
return {}
|
|
107
|
+
for path in (("data", "portfolio"), ("portfolio",), ("data",)):
|
|
108
|
+
cur = res
|
|
109
|
+
for k in path:
|
|
110
|
+
cur = cur.get(k) if isinstance(cur, dict) else None
|
|
111
|
+
if isinstance(cur, dict) and any(
|
|
112
|
+
k in cur for k in ("total_in_hyperliquid", "token_balances",
|
|
113
|
+
"total_balance_usd", "total_withdrawable")
|
|
114
|
+
):
|
|
115
|
+
return cur
|
|
116
|
+
return res
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _available_usd(res) -> Optional[float]:
|
|
120
|
+
"""Total VISIBLE funding balance = free Hyperliquid USDC + free EVM USDC, summed the way the
|
|
121
|
+
backend funds a create. Returns None when nothing readable (the caller then proceeds rather
|
|
122
|
+
than blocking on an unreadable balance — deploy.py did the same, deploy.py:103).
|
|
123
|
+
|
|
124
|
+
Deliberately NOT ``total_withdrawable``: that field is idle-margin sitting INSIDE existing
|
|
125
|
+
strategy wallets (the documented trap in senpi-portfolio), not cash spendable on a NEW wallet.
|
|
126
|
+
We only count the truly-free embedded buckets (HL + EVM USDC)."""
|
|
127
|
+
port = _portfolio_obj(res)
|
|
128
|
+
hl = _num(_dig(port, "total_in_hyperliquid"))
|
|
129
|
+
evm: Optional[float] = None
|
|
130
|
+
tb = _dig(port, "token_balances")
|
|
131
|
+
if isinstance(tb, list):
|
|
132
|
+
total, seen = 0.0, False
|
|
133
|
+
for t in tb:
|
|
134
|
+
if not isinstance(t, dict):
|
|
135
|
+
continue
|
|
136
|
+
sym = str(_dig(t, "symbol", "token", "asset") or "").upper()
|
|
137
|
+
val = _num(_dig(t, "usdValue", "usd_value", "valueUsd", "usd"))
|
|
138
|
+
if val is None:
|
|
139
|
+
continue
|
|
140
|
+
if "USD" in sym: # USDC / USDT / USD stables — the fundable EVM cash
|
|
141
|
+
total += val
|
|
142
|
+
seen = True
|
|
143
|
+
if seen or tb == []:
|
|
144
|
+
evm = total
|
|
145
|
+
if hl is None and evm is None:
|
|
146
|
+
return None
|
|
147
|
+
return (hl or 0.0) + (evm or 0.0)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------------------- helpers
|
|
151
|
+
|
|
152
|
+
def wallet_json_path(base_state_dir: str, strategy: str) -> Path:
|
|
153
|
+
"""The strategy-level wallet artifact — ABOVE the ``<hash8>`` install dirs on purpose (the
|
|
154
|
+
wallet is the strategy instance identity across content versions)."""
|
|
155
|
+
return Path(base_state_dir) / "strategies" / strategy / WALLET_JSON
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _sanitize_strategy_name(strategy: str) -> str:
|
|
159
|
+
"""The wallet's display name (strategyName): [A-Za-z0-9_-], whitespace→'-', ≤40 chars — the
|
|
160
|
+
same rule deploy.py:215 applies so wallets are legible in the app, not bare 0x addresses."""
|
|
161
|
+
s = re.sub(r"[^A-Za-z0-9_-]", "", re.sub(r"\s+", "-", str(strategy).strip())).strip("-_")[:40]
|
|
162
|
+
return s or str(strategy)[:40]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _staged_version(staged: Path) -> str:
|
|
166
|
+
"""Best-effort ``version`` from the staged strategy.yaml (attribution skillVersion); defaults
|
|
167
|
+
to 0.1.0 (the render_strategy_yaml default) if unreadable."""
|
|
168
|
+
try:
|
|
169
|
+
text = (staged / "strategy.yaml").read_text(encoding="utf-8")
|
|
170
|
+
except OSError:
|
|
171
|
+
return "0.1.0"
|
|
172
|
+
m = re.search(r'(?m)^version:\s*"?([^"\n]+?)"?\s*$', text)
|
|
173
|
+
return m.group(1).strip() if m else "0.1.0"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _read_wallet_json(path: Path) -> Optional[dict]:
|
|
177
|
+
try:
|
|
178
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
179
|
+
except (OSError, json.JSONDecodeError):
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _write_wallet_json(path: Path, *, strategy: str, wallet: str, strategy_id: str,
|
|
184
|
+
budget: float, created_at: Optional[str]) -> None:
|
|
185
|
+
"""Record the minted wallet BESIDE the strategy (the resumable-by-artifact marker). Written
|
|
186
|
+
BEFORE polling so an interrupted run resumes at step (a). ``created_at`` is an INJECTED value
|
|
187
|
+
(no wall-clock in the Python core) — None when the caller omits it."""
|
|
188
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
payload = {
|
|
190
|
+
"strategy": strategy,
|
|
191
|
+
"wallet": wallet,
|
|
192
|
+
"strategyId": strategy_id,
|
|
193
|
+
"budget": budget,
|
|
194
|
+
"created_at": created_at,
|
|
195
|
+
}
|
|
196
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _match_strategy(strategies: list, strategy_id: Optional[str], wallet: Optional[str]):
|
|
200
|
+
"""Find the strategy record by recorded strategyId first (survives an empty/unminted wallet),
|
|
201
|
+
then by wallet address."""
|
|
202
|
+
if strategy_id:
|
|
203
|
+
for s in strategies:
|
|
204
|
+
if _strat_id(s) == strategy_id:
|
|
205
|
+
return s
|
|
206
|
+
if wallet:
|
|
207
|
+
for s in strategies:
|
|
208
|
+
if wallet_match(_strat_wallet(s), wallet):
|
|
209
|
+
return s
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --------------------------------------------------------------------------- result
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class FundResult:
|
|
217
|
+
target: str
|
|
218
|
+
verdict: str
|
|
219
|
+
strategy: str = ""
|
|
220
|
+
wallet: str = ""
|
|
221
|
+
strategy_id: Optional[str] = None
|
|
222
|
+
budget: Optional[float] = None
|
|
223
|
+
available: Optional[float] = None
|
|
224
|
+
pre_action: str = "" # the unmistakable money-mutation line (create path only)
|
|
225
|
+
errors: list[TeachingError] = field(default_factory=list)
|
|
226
|
+
message: str = ""
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def ok(self) -> bool:
|
|
230
|
+
return self.verdict in (FUNDED, PENDING)
|
|
231
|
+
|
|
232
|
+
def to_json(self) -> dict:
|
|
233
|
+
return {
|
|
234
|
+
"verb": "fund",
|
|
235
|
+
"target": self.target,
|
|
236
|
+
"verdict": self.verdict,
|
|
237
|
+
"strategy": self.strategy,
|
|
238
|
+
"wallet": self.wallet,
|
|
239
|
+
"strategy_id": self.strategy_id,
|
|
240
|
+
"budget": self.budget,
|
|
241
|
+
"available": self.available,
|
|
242
|
+
"pre_action": self.pre_action,
|
|
243
|
+
"error_count": len(self.errors),
|
|
244
|
+
"errors": [e.to_dict() for e in self.errors],
|
|
245
|
+
"message": self.message,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def render(self) -> str:
|
|
249
|
+
lines = [f"{self.verdict}: {self.target}"]
|
|
250
|
+
if self.pre_action:
|
|
251
|
+
lines.append(f" {self.pre_action}")
|
|
252
|
+
if self.strategy:
|
|
253
|
+
lines.append(f" strategy : {self.strategy}")
|
|
254
|
+
if self.wallet:
|
|
255
|
+
lines.append(f" wallet : {self.wallet}")
|
|
256
|
+
if self.strategy_id:
|
|
257
|
+
lines.append(f" strategyId : {self.strategy_id}")
|
|
258
|
+
if self.budget is not None:
|
|
259
|
+
lines.append(f" budget : ${self.budget:,.2f}")
|
|
260
|
+
if self.message:
|
|
261
|
+
lines.append(f" {self.message}")
|
|
262
|
+
if self.verdict == FUNDED and self.wallet:
|
|
263
|
+
lines.append(f" next: `composer install <target>` (auto-binds this wallet), or "
|
|
264
|
+
f"`--wallet {self.wallet}`.")
|
|
265
|
+
if self.verdict == PENDING:
|
|
266
|
+
lines.append(" the wallet is still initializing — re-run `composer fund` to poll "
|
|
267
|
+
"(idempotent; it never re-funds).")
|
|
268
|
+
for e in self.errors:
|
|
269
|
+
lines.append(e.render())
|
|
270
|
+
return "\n".join(lines) + "\n"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _refused(target: str, strategy: str, error: TeachingError, **kw) -> FundResult:
|
|
274
|
+
return FundResult(target=target, verdict=REFUSED, strategy=strategy, errors=[error],
|
|
275
|
+
message=error.message, **kw)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# --------------------------------------------------------------------------- the verb
|
|
279
|
+
|
|
280
|
+
def run_fund(
|
|
281
|
+
target: str,
|
|
282
|
+
budget: Optional[float],
|
|
283
|
+
base_state_dir: str,
|
|
284
|
+
*,
|
|
285
|
+
out_dir: Optional[str] = None,
|
|
286
|
+
created_at: Optional[str] = None,
|
|
287
|
+
mcp_fn: McpFn = _box.default_mcp_call,
|
|
288
|
+
sleep_fn: Callable[[float], None] = time.sleep,
|
|
289
|
+
poll_attempts: int = 3,
|
|
290
|
+
poll_interval: float = 5.0,
|
|
291
|
+
) -> FundResult:
|
|
292
|
+
"""Create + fund a strategy's wallet (see module docstring)."""
|
|
293
|
+
# (0) --budget gate — REQUIRED, EXPLICIT, ≥ the platform floor. Never derived or split.
|
|
294
|
+
if budget is None or _num(budget) is None or float(budget) < MIN_BUDGET:
|
|
295
|
+
shown = "(missing)" if budget is None else f"${float(budget):,.2f}" if _num(budget) is not None else repr(budget)
|
|
296
|
+
return _refused(target, "", TeachingError(
|
|
297
|
+
"CMP230", f"`--budget` is {shown}; it must be an explicit USD amount ≥ "
|
|
298
|
+
f"${MIN_BUDGET:,.0f} (the platform minimum per strategy wallet).",
|
|
299
|
+
f"Agree the budget with the user, then pass it explicitly: "
|
|
300
|
+
f"`composer fund <target> --budget N` with N ≥ {MIN_BUDGET:,.0f}. The composer never "
|
|
301
|
+
f"derives or splits a budget — the exact amount you pass funds this one wallet.",
|
|
302
|
+
Location(),
|
|
303
|
+
), budget=_num(budget))
|
|
304
|
+
budget = float(budget)
|
|
305
|
+
|
|
306
|
+
# (1) resolve the staged unit → strategy name (+ version for attribution).
|
|
307
|
+
staged = _resolve_staged(target, out_dir)
|
|
308
|
+
if staged is None:
|
|
309
|
+
return _refused(target, "", TeachingError(
|
|
310
|
+
"CMP200", "the fund target is not a staged unit (no manifest.json found).",
|
|
311
|
+
"Point `composer fund` at a `composer deploy` staging dir, or pass the graph file plus "
|
|
312
|
+
"`-o <staging-dir>`. Run `composer deploy <graph> -o <dir>` first.",
|
|
313
|
+
Location(),
|
|
314
|
+
), budget=budget)
|
|
315
|
+
try:
|
|
316
|
+
manifest = json.loads((staged / "manifest.json").read_text(encoding="utf-8"))
|
|
317
|
+
except (OSError, json.JSONDecodeError):
|
|
318
|
+
manifest = {}
|
|
319
|
+
strategy = str(manifest.get("strategy") or staged.name)
|
|
320
|
+
version = _staged_version(staged)
|
|
321
|
+
wj_path = wallet_json_path(base_state_dir, strategy)
|
|
322
|
+
|
|
323
|
+
# (a) EXISTING wallet.json — verify via strategy_list; never re-fund.
|
|
324
|
+
existing = _read_wallet_json(wj_path)
|
|
325
|
+
if existing is not None:
|
|
326
|
+
return _verify_existing(target, strategy, budget, existing, wj_path, mcp_fn, created_at)
|
|
327
|
+
|
|
328
|
+
# (b) PREFLIGHT the visible balance (no wallet yet).
|
|
329
|
+
try:
|
|
330
|
+
portfolio = mcp_fn("account_get_portfolio", timeout=_box._SUBMIT_TIMEOUT)
|
|
331
|
+
except McpCredsMissing as e:
|
|
332
|
+
return _refused(target, strategy, _creds_error(e), budget=budget)
|
|
333
|
+
except McpError:
|
|
334
|
+
portfolio = None # transport hiccup — proceed unverified (create fails loud if truly short)
|
|
335
|
+
available = _available_usd(portfolio)
|
|
336
|
+
if available is not None and available < budget:
|
|
337
|
+
short = budget - available
|
|
338
|
+
return _refused(target, strategy, TeachingError(
|
|
339
|
+
"CMP232", f"visible balance ${available:,.2f} is short of the ${budget:,.2f} budget "
|
|
340
|
+
f"by ${short:,.2f}.",
|
|
341
|
+
"Top up the embedded wallet, then re-run `composer fund` (idempotent). The visible "
|
|
342
|
+
"total sums your FREE Hyperliquid + EVM USDC. If you JUST moved funds from Hyperliquid "
|
|
343
|
+
"to EVM, the backend can lag ~a minute before it sees them — wait and re-run before "
|
|
344
|
+
"topping up more. To add funds: deposit USDC to your embedded wallet, or bridge from "
|
|
345
|
+
"Hyperliquid. The composer NEVER bridges or splits the budget for you.",
|
|
346
|
+
Location(),
|
|
347
|
+
), budget=budget, available=available)
|
|
348
|
+
|
|
349
|
+
# (c) CREATE — a money mutation. State exactly what is being done (no --yes; conversational).
|
|
350
|
+
pre_action = (f"CREATING WALLET — strategy={strategy!r}, budget=${budget:,.2f}, "
|
|
351
|
+
f"funding source=your embedded balance (Hyperliquid + EVM), "
|
|
352
|
+
f"attribution skillName={strategy!r}."
|
|
353
|
+
+ ("" if available is not None
|
|
354
|
+
else " (balance could not be pre-verified; the backend will reject if short.)"))
|
|
355
|
+
sname = _sanitize_strategy_name(strategy)
|
|
356
|
+
|
|
357
|
+
def _create(with_name: bool):
|
|
358
|
+
kw = dict(initialBudget=budget, positions=[], skillName=strategy, skillVersion=version,
|
|
359
|
+
timeout=_box._SUBMIT_TIMEOUT)
|
|
360
|
+
if with_name:
|
|
361
|
+
kw["strategyName"] = sname
|
|
362
|
+
return mcp_fn("strategy_create_custom_strategy", **kw)
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
res = _create(with_name=True)
|
|
366
|
+
except McpCredsMissing as e:
|
|
367
|
+
return _refused(target, strategy, _creds_error(e), budget=budget, available=available,
|
|
368
|
+
pre_action=pre_action)
|
|
369
|
+
except McpError as e:
|
|
370
|
+
msg = str(e)
|
|
371
|
+
if any(c in msg for c in _NAME_ERR_CODES) or "name" in msg.lower():
|
|
372
|
+
try:
|
|
373
|
+
res = _create(with_name=False) # naming is best-effort — retry unnamed
|
|
374
|
+
except McpError as e2:
|
|
375
|
+
return _create_failed(target, strategy, budget, available, pre_action, e2)
|
|
376
|
+
else:
|
|
377
|
+
return _create_failed(target, strategy, budget, available, pre_action, e)
|
|
378
|
+
|
|
379
|
+
sid = _strat_id(res)
|
|
380
|
+
if not sid:
|
|
381
|
+
return _create_failed(target, strategy, budget, available, pre_action,
|
|
382
|
+
f"create returned no strategyId: {res!r}")
|
|
383
|
+
addr = str(_strat_wallet(res) or "")
|
|
384
|
+
|
|
385
|
+
# Record BEFORE polling — an interrupted run resumes at step (a), never double-funds.
|
|
386
|
+
_write_wallet_json(wj_path, strategy=strategy, wallet=addr, strategy_id=sid,
|
|
387
|
+
budget=budget, created_at=created_at)
|
|
388
|
+
|
|
389
|
+
# Poll strategy_list BRIEFLY for ACTIVE (bounded; still pending → PENDING, not a failure).
|
|
390
|
+
for attempt in range(max(1, poll_attempts)):
|
|
391
|
+
try:
|
|
392
|
+
strategies = _find_list(mcp_fn("strategy_list", status=LIVE_STATUSES), "strategies")
|
|
393
|
+
except McpError:
|
|
394
|
+
strategies = []
|
|
395
|
+
s = _match_strategy(strategies, sid, addr)
|
|
396
|
+
status = str(_strat_status(s) or "").upper() if s else ""
|
|
397
|
+
cur_addr = str(_strat_wallet(s) or "") if s else ""
|
|
398
|
+
if status == "ACTIVE" and cur_addr:
|
|
399
|
+
_write_wallet_json(wj_path, strategy=strategy, wallet=cur_addr, strategy_id=sid,
|
|
400
|
+
budget=budget, created_at=created_at)
|
|
401
|
+
return FundResult(
|
|
402
|
+
target=target, verdict=FUNDED, strategy=strategy, wallet=cur_addr,
|
|
403
|
+
strategy_id=sid, budget=budget, available=available, pre_action=pre_action,
|
|
404
|
+
message=f"wallet ACTIVE and funded with ${budget:,.2f}.",
|
|
405
|
+
)
|
|
406
|
+
if attempt < poll_attempts - 1:
|
|
407
|
+
sleep_fn(poll_interval)
|
|
408
|
+
|
|
409
|
+
return FundResult(
|
|
410
|
+
target=target, verdict=PENDING, strategy=strategy, wallet=addr, strategy_id=sid,
|
|
411
|
+
budget=budget, available=available, pre_action=pre_action,
|
|
412
|
+
message=f"wallet created (strategyId {sid}) and funding; not ACTIVE yet.",
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# --------------------------------------------------------------------------- sub-flows
|
|
417
|
+
|
|
418
|
+
def _verify_existing(target: str, strategy: str, budget: float, rec: dict, wj_path: Path,
|
|
419
|
+
mcp_fn: McpFn, created_at: Optional[str]) -> FundResult:
|
|
420
|
+
"""Step (a): a wallet.json already exists — verify it via strategy_list, never re-fund."""
|
|
421
|
+
rec_wallet = str(rec.get("wallet") or "")
|
|
422
|
+
rec_sid = rec.get("strategyId")
|
|
423
|
+
try:
|
|
424
|
+
strategies = _find_list(mcp_fn("strategy_list", status=LIVE_STATUSES), "strategies")
|
|
425
|
+
mcp_ok = True
|
|
426
|
+
except McpCredsMissing as e:
|
|
427
|
+
return _refused(target, strategy, _creds_error(e), wallet=rec_wallet,
|
|
428
|
+
strategy_id=rec_sid, budget=budget)
|
|
429
|
+
except McpError:
|
|
430
|
+
strategies, mcp_ok = [], False
|
|
431
|
+
|
|
432
|
+
s = _match_strategy(strategies, rec_sid, rec_wallet)
|
|
433
|
+
status = str(_strat_status(s) or "").upper() if s else ""
|
|
434
|
+
addr = str(_strat_wallet(s) or "") if s else rec_wallet
|
|
435
|
+
|
|
436
|
+
if status == "ACTIVE":
|
|
437
|
+
# Backfill the wallet address if the recorded one was empty (created-but-not-yet-ACTIVE run).
|
|
438
|
+
if addr and addr != rec_wallet:
|
|
439
|
+
_write_wallet_json(wj_path, strategy=strategy, wallet=addr,
|
|
440
|
+
strategy_id=rec_sid or _strat_id(s), budget=budget,
|
|
441
|
+
created_at=rec.get("created_at") or created_at)
|
|
442
|
+
return FundResult(
|
|
443
|
+
target=target, verdict=FUNDED, strategy=strategy, wallet=addr or rec_wallet,
|
|
444
|
+
strategy_id=rec_sid or _strat_id(s), budget=rec.get("budget", budget),
|
|
445
|
+
message="wallet already funded and ACTIVE (no-op).",
|
|
446
|
+
)
|
|
447
|
+
if s is not None and status and status not in CLOSED_STATUSES:
|
|
448
|
+
return FundResult(
|
|
449
|
+
target=target, verdict=PENDING, strategy=strategy, wallet=addr or rec_wallet,
|
|
450
|
+
strategy_id=rec_sid or _strat_id(s), budget=rec.get("budget", budget),
|
|
451
|
+
message=f"wallet still initializing (status {status}).",
|
|
452
|
+
)
|
|
453
|
+
if not mcp_ok:
|
|
454
|
+
# Could not reach MCP to confirm — do NOT declare stale; re-run once reachable.
|
|
455
|
+
return FundResult(
|
|
456
|
+
target=target, verdict=PENDING, strategy=strategy, wallet=rec_wallet,
|
|
457
|
+
strategy_id=rec_sid, budget=rec.get("budget", budget),
|
|
458
|
+
message="could not reach MCP to verify the recorded wallet — re-run `composer fund`.",
|
|
459
|
+
)
|
|
460
|
+
# Found closed, or not present in the live set at all → the recorded wallet is stale.
|
|
461
|
+
return _refused(target, strategy, TeachingError(
|
|
462
|
+
"CMP231", f"the recorded wallet ({rec_wallet or 'strategyId ' + str(rec_sid)}) is "
|
|
463
|
+
f"{status or 'no longer in your active strategies'} — wallet.json is stale.",
|
|
464
|
+
f"Its strategy was closed or is gone, so it cannot be installed. To fund a FRESH wallet "
|
|
465
|
+
f"for {strategy!r}, delete the stale marker `{wj_path}` and re-run `composer fund`. If you "
|
|
466
|
+
f"believe it is still open, check MCP `strategy_list` (it may be filtered out as terminal).",
|
|
467
|
+
Location(),
|
|
468
|
+
), wallet=rec_wallet, strategy_id=rec_sid, budget=budget)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _creds_error(exc: Exception) -> TeachingError:
|
|
472
|
+
return TeachingError(
|
|
473
|
+
"CMP223", f"MCP credentials missing: {exc}",
|
|
474
|
+
"Set SENPI_AUTH_TOKEN or SENPI_API_KEY (and optionally SENPI_MCP_URL) in the environment — "
|
|
475
|
+
"fund needs the MCP server to read your balance, create the wallet, and poll it to ACTIVE.",
|
|
476
|
+
Location(),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _create_failed(target: str, strategy: str, budget: float, available: Optional[float],
|
|
481
|
+
pre_action: str, err) -> FundResult:
|
|
482
|
+
return FundResult(
|
|
483
|
+
target=target, verdict=FAILED, strategy=strategy, budget=budget, available=available,
|
|
484
|
+
pre_action=pre_action,
|
|
485
|
+
errors=[TeachingError(
|
|
486
|
+
"CMP233", f"MCP `strategy_create_custom_strategy` failed: {str(err)[:200]}.",
|
|
487
|
+
"No wallet was created (nothing to clean up). Re-run `composer fund` — it is safe "
|
|
488
|
+
"(no wallet.json was written). If it keeps failing, verify MCP creds are valid and "
|
|
489
|
+
"that your balance covers the budget plus the small creation fee.",
|
|
490
|
+
Location(),
|
|
491
|
+
)],
|
|
492
|
+
message="wallet creation failed (see below).",
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def fund_target(target, budget, base_state_dir, **kw) -> FundResult:
|
|
497
|
+
"""Path-taking convenience wrapper mirroring ``install_target``."""
|
|
498
|
+
return run_fund(str(target), budget, str(base_state_dir), **kw)
|