primecli 0.2.6__tar.gz → 0.2.7__tar.gz
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.
- {primecli-0.2.6 → primecli-0.2.7}/PKG-INFO +1 -1
- {primecli-0.2.6 → primecli-0.2.7}/primecli/degenprime.py +23 -0
- {primecli-0.2.6 → primecli-0.2.7}/primecli/deltaprime.py +22 -0
- primecli-0.2.7/primecli/health_monitor.py +538 -0
- {primecli-0.2.6 → primecli-0.2.7}/primecli.egg-info/PKG-INFO +1 -1
- {primecli-0.2.6 → primecli-0.2.7}/primecli.egg-info/SOURCES.txt +1 -0
- {primecli-0.2.6 → primecli-0.2.7}/pyproject.toml +1 -1
- {primecli-0.2.6 → primecli-0.2.7}/LICENSE +0 -0
- {primecli-0.2.6 → primecli-0.2.7}/README.md +0 -0
- {primecli-0.2.6 → primecli-0.2.7}/primecli/__init__.py +0 -0
- {primecli-0.2.6 → primecli-0.2.7}/primecli.egg-info/dependency_links.txt +0 -0
- {primecli-0.2.6 → primecli-0.2.7}/primecli.egg-info/entry_points.txt +0 -0
- {primecli-0.2.6 → primecli-0.2.7}/primecli.egg-info/requires.txt +0 -0
- {primecli-0.2.6 → primecli-0.2.7}/primecli.egg-info/top_level.txt +0 -0
- {primecli-0.2.6 → primecli-0.2.7}/setup.cfg +0 -0
- {primecli-0.2.6 → primecli-0.2.7}/tests/test_redstone_encoding.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: primecli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Agent-friendly CLI tools for the DeltaPrime (Avalanche) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required.
|
|
5
5
|
Author: Mnemosyne-quest contributors
|
|
6
6
|
License: MIT
|
|
@@ -86,6 +86,23 @@ from eth_keys import keys as eth_keys
|
|
|
86
86
|
from eth_abi import decode as abi_decode
|
|
87
87
|
from web3 import Web3
|
|
88
88
|
|
|
89
|
+
# Health monitoring sub-system
|
|
90
|
+
_hm = None
|
|
91
|
+
for _mod in ('primecli.health_monitor', 'health_monitor'):
|
|
92
|
+
try:
|
|
93
|
+
_hm = __import__(_mod, fromlist=['cli'])
|
|
94
|
+
break
|
|
95
|
+
except ImportError:
|
|
96
|
+
continue
|
|
97
|
+
if _hm is None:
|
|
98
|
+
import importlib
|
|
99
|
+
_hm_path = Path(__file__).parent / 'health_monitor.py'
|
|
100
|
+
if _hm_path.exists():
|
|
101
|
+
_spec = importlib.util.spec_from_file_location('health_monitor', _hm_path)
|
|
102
|
+
_hm = importlib.util.module_from_spec(_spec)
|
|
103
|
+
_spec.loader.exec_module(_hm)
|
|
104
|
+
health_monitor = _hm
|
|
105
|
+
|
|
89
106
|
# Default Base RPC. mainnet.base.org rate-limits hard (429 within a few calls); the
|
|
90
107
|
# publicnode endpoint is fronted by a load balancer with much higher anonymous limits
|
|
91
108
|
# and has been the most reliable free option for this tool's traffic pattern (lots of
|
|
@@ -2368,6 +2385,12 @@ def _dispatch():
|
|
|
2368
2385
|
cmd_execute_pool_withdrawal(pool, index, execute)
|
|
2369
2386
|
elif cmd == "aerodrome-positions":
|
|
2370
2387
|
cmd_aerodrome_positions()
|
|
2388
|
+
elif cmd == "health":
|
|
2389
|
+
os.environ.setdefault("PRIMECLI_TOOL", sys.argv[0])
|
|
2390
|
+
if health_monitor:
|
|
2391
|
+
health_monitor.cli()
|
|
2392
|
+
else:
|
|
2393
|
+
print("health_monitor module not available")
|
|
2371
2394
|
else:
|
|
2372
2395
|
print(f"Unknown command: {cmd}\n{__doc__}")
|
|
2373
2396
|
|
|
@@ -211,6 +211,25 @@ from eth_abi import encode as abi_encode, decode as abi_decode
|
|
|
211
211
|
from web3 import Web3
|
|
212
212
|
from web3.middleware import ExtraDataToPOAMiddleware
|
|
213
213
|
|
|
214
|
+
# Health monitoring sub-system
|
|
215
|
+
# Try both package import (installed) and local import (standalone script)
|
|
216
|
+
_hm = None
|
|
217
|
+
for _mod in ('primecli.health_monitor', 'health_monitor'):
|
|
218
|
+
try:
|
|
219
|
+
_hm = __import__(_mod, fromlist=['cli'])
|
|
220
|
+
break
|
|
221
|
+
except ImportError:
|
|
222
|
+
continue
|
|
223
|
+
if _hm is None:
|
|
224
|
+
# Last resort: find health_monitor.py next to this script
|
|
225
|
+
import importlib.util
|
|
226
|
+
_hm_path = Path(__file__).parent / 'health_monitor.py'
|
|
227
|
+
if _hm_path.exists():
|
|
228
|
+
_spec = importlib.util.spec_from_file_location('health_monitor', _hm_path)
|
|
229
|
+
_hm = importlib.util.module_from_spec(_spec)
|
|
230
|
+
_spec.loader.exec_module(_hm)
|
|
231
|
+
health_monitor = _hm
|
|
232
|
+
|
|
214
233
|
AVALANCHE_RPC = "https://api.avax.network/ext/bc/C/rpc"
|
|
215
234
|
EXPLORER = "https://snowtrace.io"
|
|
216
235
|
CHAIN_ID = 43114
|
|
@@ -5169,6 +5188,9 @@ def main():
|
|
|
5169
5188
|
return
|
|
5170
5189
|
cmd_zap(market, collateral, collateral_amount, borrow_amount, deposit_amount,
|
|
5171
5190
|
side, swap_to_long, slippage, fee_buffer, execute)
|
|
5191
|
+
elif cmd == "health":
|
|
5192
|
+
os.environ.setdefault("PRIMECLI_TOOL", sys.argv[0])
|
|
5193
|
+
health_monitor.cli()
|
|
5172
5194
|
else:
|
|
5173
5195
|
print(f"Unknown command: {cmd}\n{__doc__}")
|
|
5174
5196
|
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Health monitoring and strategy system for Prime/Degen accounts.
|
|
2
|
+
|
|
3
|
+
Sits on top of the primecli tool commands (defi --json, borrow, repay, etc.)
|
|
4
|
+
to provide automated health tracking, configurable rebalancing, and equity-drawdown
|
|
5
|
+
stop-loss. Designed for cron-based operation.
|
|
6
|
+
|
|
7
|
+
Two modes:
|
|
8
|
+
Observer (default) — logs state, escalates on issues. No auto-actions.
|
|
9
|
+
Rebalance — with a strategy.json, auto-rebalances within a target range.
|
|
10
|
+
|
|
11
|
+
Strategy config (JSON):
|
|
12
|
+
{
|
|
13
|
+
"mode": "rebalance",
|
|
14
|
+
"target_range": [30, 70],
|
|
15
|
+
"center": 50,
|
|
16
|
+
"cooldown_secs": 3600,
|
|
17
|
+
"position": "gmx",
|
|
18
|
+
"market": "avax-usdc",
|
|
19
|
+
"side": "short"
|
|
20
|
+
}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
# ── Tier config ─────────────────────────────────────────────────────
|
|
32
|
+
TIER_MAX = {"basic": 5, "premium": 10}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ════════════════════════════════════════════════════════════════════
|
|
36
|
+
# Health computation
|
|
37
|
+
# ════════════════════════════════════════════════════════════════════
|
|
38
|
+
|
|
39
|
+
def compute_health(defi_data: dict, max_mult: int = 10) -> dict:
|
|
40
|
+
"""Compute Bruno's 0-100% health scale from defi --json data.
|
|
41
|
+
|
|
42
|
+
Formula:
|
|
43
|
+
equity = total_supplied_usd - total_debt_usd
|
|
44
|
+
max_debt = equity * (tier - 1) # PREMIUM=10, BASIC=5
|
|
45
|
+
health% = (max_debt - debt) / max_debt * 100
|
|
46
|
+
|
|
47
|
+
Returns dict with health metrics or error.
|
|
48
|
+
"""
|
|
49
|
+
# Parse groups (DeltaPrime format) or flat format (DegenPrime)
|
|
50
|
+
groups = defi_data.get("groups", [])
|
|
51
|
+
if groups:
|
|
52
|
+
g = groups[0]
|
|
53
|
+
supplied = g.get("supplied", [])
|
|
54
|
+
borrowed = g.get("borrowed", [])
|
|
55
|
+
health_ratio = g.get("health_ratio", 0) or 0
|
|
56
|
+
else:
|
|
57
|
+
supplied = defi_data.get("supplied", [])
|
|
58
|
+
borrowed = defi_data.get("borrowed", [])
|
|
59
|
+
health_ratio = 0
|
|
60
|
+
|
|
61
|
+
supplied_usd = sum(s.get("usd", 0) or 0 for s in supplied)
|
|
62
|
+
debt_usd = sum(b.get("usd", 0) or 0 for b in borrowed)
|
|
63
|
+
equity = supplied_usd - debt_usd
|
|
64
|
+
|
|
65
|
+
if equity <= 0.01:
|
|
66
|
+
return {
|
|
67
|
+
"bruno_pct": 0.0,
|
|
68
|
+
"health_ratio": round(health_ratio, 4),
|
|
69
|
+
"supplied_usd": round(supplied_usd, 2),
|
|
70
|
+
"debt_usd": round(debt_usd, 2),
|
|
71
|
+
"equity": round(equity, 2),
|
|
72
|
+
"error": "equity near zero",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
max_debt = equity * (max_mult - 1)
|
|
76
|
+
|
|
77
|
+
# Raw USDC in account
|
|
78
|
+
raw_usdc = sum(s.get("usd", 0) for s in supplied if s.get("symbol") == "USDC")
|
|
79
|
+
|
|
80
|
+
# Position type detection
|
|
81
|
+
symbols = [s.get("symbol", "") for s in supplied]
|
|
82
|
+
has_gmx = any("GM_" in sym for sym in symbols)
|
|
83
|
+
has_lb = any(
|
|
84
|
+
sym in ("LB_AVAX_USDC", "LB_WAVAX_USDC", "JOE")
|
|
85
|
+
or "TRADERJOE" in sym.upper()
|
|
86
|
+
for sym in symbols
|
|
87
|
+
)
|
|
88
|
+
has_aero = any("AERO" in sym.upper() or "CL_POSITION" in sym.upper() for sym in symbols)
|
|
89
|
+
|
|
90
|
+
if max_debt > 0 and debt_usd >= 0:
|
|
91
|
+
bruno_pct = (max_debt - min(debt_usd, max_debt)) / max_debt * 100
|
|
92
|
+
bruno_pct = max(0.0, min(100.0, bruno_pct))
|
|
93
|
+
else:
|
|
94
|
+
bruno_pct = 100.0
|
|
95
|
+
|
|
96
|
+
delta_debt = (max_debt * 0.5) - debt_usd # center target = 50%
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"bruno_pct": round(bruno_pct, 1),
|
|
100
|
+
"health_ratio": round(health_ratio, 4),
|
|
101
|
+
"supplied_usd": round(supplied_usd, 2),
|
|
102
|
+
"debt_usd": round(debt_usd, 2),
|
|
103
|
+
"equity": round(equity, 2),
|
|
104
|
+
"max_debt": round(max_debt, 2),
|
|
105
|
+
"delta_debt": round(delta_debt, 2),
|
|
106
|
+
"raw_usdc": round(raw_usdc, 2),
|
|
107
|
+
"has_gmx": has_gmx,
|
|
108
|
+
"has_lb": has_lb,
|
|
109
|
+
"has_aero": has_aero,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ════════════════════════════════════════════════════════════════════
|
|
114
|
+
# Strategy loading
|
|
115
|
+
# ════════════════════════════════════════════════════════════════════
|
|
116
|
+
|
|
117
|
+
def load_strategy(strategy_path: str) -> dict:
|
|
118
|
+
"""Load strategy config from JSON file. Returns empty dict if not found."""
|
|
119
|
+
path = Path(strategy_path)
|
|
120
|
+
if not path.exists():
|
|
121
|
+
return {}
|
|
122
|
+
with open(path) as f:
|
|
123
|
+
return json.load(f)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ════════════════════════════════════════════════════════════════════
|
|
127
|
+
# State / history helpers
|
|
128
|
+
# ════════════════════════════════════════════════════════════════════
|
|
129
|
+
|
|
130
|
+
def append_history(state_dir: str, entry: dict):
|
|
131
|
+
"""Append one health tick to the JSONL history file."""
|
|
132
|
+
path = Path(state_dir) / "history.jsonl"
|
|
133
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
with open(path, "a") as f:
|
|
135
|
+
f.write(json.dumps(entry) + "\n")
|
|
136
|
+
# Trim to last 1000 lines
|
|
137
|
+
lines = path.read_text().strip().split("\n")
|
|
138
|
+
if len(lines) > 1000:
|
|
139
|
+
path.write_text("\n".join(lines[-1000:]) + "\n")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_baseline_equity(state_dir: str) -> float | None:
|
|
143
|
+
"""Load baseline equity for stop-loss tracking."""
|
|
144
|
+
path = Path(state_dir) / "baseline-equity"
|
|
145
|
+
if path.exists():
|
|
146
|
+
try:
|
|
147
|
+
return float(path.read_text().strip())
|
|
148
|
+
except (ValueError, OSError):
|
|
149
|
+
return None
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def save_baseline_equity(state_dir: str, equity: float):
|
|
154
|
+
"""Save baseline equity for stop-loss tracking."""
|
|
155
|
+
path = Path(state_dir) / "baseline-equity"
|
|
156
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
path.write_text(str(equity))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_last_health(state_dir: str) -> float | None:
|
|
161
|
+
"""Load last recorded health pct for swing detection."""
|
|
162
|
+
path = Path(state_dir) / "last-health-pct"
|
|
163
|
+
if path.exists():
|
|
164
|
+
try:
|
|
165
|
+
return float(path.read_text().strip())
|
|
166
|
+
except (ValueError, OSError):
|
|
167
|
+
return None
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def save_last_health(state_dir: str, pct: float):
|
|
172
|
+
"""Save current health pct."""
|
|
173
|
+
path = Path(state_dir) / "last-health-pct"
|
|
174
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
path.write_text(str(pct))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def write_escalation(state_dir: str, reason: str, payload: dict):
|
|
179
|
+
"""Write escalation marker for the escalation handler to pick up."""
|
|
180
|
+
path = Path(state_dir) / "escalate.json"
|
|
181
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
with open(path, "w") as f:
|
|
183
|
+
json.dump(payload, f)
|
|
184
|
+
# Also write a cooldown marker
|
|
185
|
+
cooldown_dir = Path(state_dir) / "last-escalation"
|
|
186
|
+
cooldown_dir.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
(cooldown_dir / reason).write_text(str(int(time.time())))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ════════════════════════════════════════════════════════════════════
|
|
191
|
+
# One tick — the core function called by cron
|
|
192
|
+
# ════════════════════════════════════════════════════════════════════
|
|
193
|
+
|
|
194
|
+
def run_tick(
|
|
195
|
+
tool_path: str,
|
|
196
|
+
strategy_path: str,
|
|
197
|
+
state_dir: str,
|
|
198
|
+
label: str = "prime",
|
|
199
|
+
dry_run: bool = False,
|
|
200
|
+
) -> dict:
|
|
201
|
+
"""Run one health monitoring tick.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
tool_path: Path to the primecli Python script (deltaprime.py or degenprime.py).
|
|
205
|
+
strategy_path: Path to strategy.json (rebalance config).
|
|
206
|
+
state_dir: Directory for state files (history, baseline, etc.).
|
|
207
|
+
label: Human label for log messages ("prime", "degen").
|
|
208
|
+
dry_run: If True, don't execute any on-chain actions (just print what would happen).
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Dict with tick result.
|
|
212
|
+
"""
|
|
213
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
214
|
+
result = {"ts": now_iso, "label": label, "mode": "observer"}
|
|
215
|
+
|
|
216
|
+
# 1. Fetch account state
|
|
217
|
+
try:
|
|
218
|
+
raw = subprocess.run(
|
|
219
|
+
[sys.executable, tool_path, "defi", "--json"],
|
|
220
|
+
capture_output=True, text=True, timeout=90,
|
|
221
|
+
)
|
|
222
|
+
if raw.returncode != 0:
|
|
223
|
+
result["error"] = f"defi failed: {raw.stderr[:200]}"
|
|
224
|
+
return result
|
|
225
|
+
defi_data = json.loads(raw.stdout)
|
|
226
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:
|
|
227
|
+
result["error"] = f"defi error: {e}"
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
# 2. Determine tier
|
|
231
|
+
try:
|
|
232
|
+
tier_out = subprocess.run(
|
|
233
|
+
[sys.executable, tool_path, "prime-tier"],
|
|
234
|
+
capture_output=True, text=True, timeout=30,
|
|
235
|
+
)
|
|
236
|
+
tier_str = tier_out.stdout
|
|
237
|
+
if "premium" in tier_str.lower():
|
|
238
|
+
max_mult = TIER_MAX.get("premium", 10)
|
|
239
|
+
tier = "premium"
|
|
240
|
+
elif "basic" in tier_str.lower():
|
|
241
|
+
max_mult = TIER_MAX.get("basic", 5)
|
|
242
|
+
tier = "basic"
|
|
243
|
+
else:
|
|
244
|
+
max_mult = TIER_MAX.get("premium", 10)
|
|
245
|
+
tier = "premium"
|
|
246
|
+
except Exception:
|
|
247
|
+
max_mult = 10
|
|
248
|
+
tier = "premium"
|
|
249
|
+
|
|
250
|
+
# 3. Compute health
|
|
251
|
+
health = compute_health(defi_data, max_mult)
|
|
252
|
+
health["tier"] = tier
|
|
253
|
+
if health.get("error") == "equity near zero":
|
|
254
|
+
write_escalation(state_dir, "equity-near-zero", {
|
|
255
|
+
"reason": "equity_near_zero",
|
|
256
|
+
"equity": health["equity"],
|
|
257
|
+
"debt": health["debt_usd"],
|
|
258
|
+
"health_pct": health["bruno_pct"],
|
|
259
|
+
"label": label,
|
|
260
|
+
})
|
|
261
|
+
result.update(health)
|
|
262
|
+
result["mode"] = "escalated"
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
# 4. Load strategy
|
|
266
|
+
strategy = load_strategy(strategy_path)
|
|
267
|
+
mode = strategy.get("mode", "observer")
|
|
268
|
+
health["mode"] = mode
|
|
269
|
+
result.update(health)
|
|
270
|
+
result["mode"] = mode
|
|
271
|
+
|
|
272
|
+
# 5. Health swing detection (always)
|
|
273
|
+
last_pct = load_last_health(state_dir)
|
|
274
|
+
if last_pct is not None and health["bruno_pct"] is not None:
|
|
275
|
+
diff = abs(health["bruno_pct"] - last_pct)
|
|
276
|
+
if diff > 10:
|
|
277
|
+
write_escalation(state_dir, "health-swing", {
|
|
278
|
+
"reason": "health_swing",
|
|
279
|
+
"from_pct": last_pct,
|
|
280
|
+
"to_pct": health["bruno_pct"],
|
|
281
|
+
"delta": diff,
|
|
282
|
+
"label": label,
|
|
283
|
+
})
|
|
284
|
+
result["escalation"] = "health_swing"
|
|
285
|
+
save_last_health(state_dir, health["bruno_pct"] or 0.0)
|
|
286
|
+
|
|
287
|
+
# 6. Append to history
|
|
288
|
+
entry = {
|
|
289
|
+
"ts": now_iso, "mode": mode,
|
|
290
|
+
"pct": health["bruno_pct"],
|
|
291
|
+
"equity": health["equity"],
|
|
292
|
+
"debt": health["debt_usd"],
|
|
293
|
+
"hr": health["health_ratio"],
|
|
294
|
+
}
|
|
295
|
+
append_history(state_dir, entry)
|
|
296
|
+
|
|
297
|
+
# 7. Rebalance mode logic
|
|
298
|
+
if mode == "rebalance":
|
|
299
|
+
target_range = strategy.get("target_range", [30, 70])
|
|
300
|
+
center = strategy.get("center", 50)
|
|
301
|
+
cooldown_secs = strategy.get("cooldown_secs", 3600)
|
|
302
|
+
stop_loss_drawdown = strategy.get("stop_loss_drawdown_pct", 0)
|
|
303
|
+
position_type = strategy.get("position", "")
|
|
304
|
+
market = strategy.get("market", "avax-usdc")
|
|
305
|
+
side = strategy.get("side", "short")
|
|
306
|
+
low, high = target_range[0], target_range[1]
|
|
307
|
+
|
|
308
|
+
pct = health["bruno_pct"]
|
|
309
|
+
equity = health["equity"]
|
|
310
|
+
debt = health["debt_usd"]
|
|
311
|
+
raw_usdc = health["raw_usdc"]
|
|
312
|
+
|
|
313
|
+
# ── Stop-loss: equity drawdown ──────────────────────────────
|
|
314
|
+
if stop_loss_drawdown > 0:
|
|
315
|
+
baseline = load_baseline_equity(state_dir)
|
|
316
|
+
if baseline is None or baseline == 0:
|
|
317
|
+
save_baseline_equity(state_dir, equity)
|
|
318
|
+
result["baseline_recorded"] = equity
|
|
319
|
+
elif equity and baseline > 0:
|
|
320
|
+
drawdown = (1 - equity / baseline) * 100
|
|
321
|
+
if drawdown >= stop_loss_drawdown:
|
|
322
|
+
write_escalation(state_dir, "stop-loss", {
|
|
323
|
+
"reason": "stop_loss_equity_drawdown",
|
|
324
|
+
"drawdown_pct": round(drawdown, 1),
|
|
325
|
+
"threshold_pct": stop_loss_drawdown,
|
|
326
|
+
"baseline_equity": baseline,
|
|
327
|
+
"current_equity": equity,
|
|
328
|
+
"debt": debt,
|
|
329
|
+
"health_pct": pct,
|
|
330
|
+
"label": label,
|
|
331
|
+
"action": "full_close",
|
|
332
|
+
})
|
|
333
|
+
result["escalation"] = "stop_loss"
|
|
334
|
+
result["action"] = "escalate_close"
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
# ── In range → no action ────────────────────────────────────
|
|
338
|
+
if low <= pct <= high:
|
|
339
|
+
result["action"] = "none"
|
|
340
|
+
return result
|
|
341
|
+
|
|
342
|
+
# ── Hard floor ─────────────────────────────────────────────
|
|
343
|
+
if pct < 20:
|
|
344
|
+
write_escalation(state_dir, "health-floor", {
|
|
345
|
+
"reason": "health_below_floor",
|
|
346
|
+
"pct": pct,
|
|
347
|
+
"equity": equity,
|
|
348
|
+
"debt": debt,
|
|
349
|
+
"label": label,
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
# ── Act ─────────────────────────────────────────────────────
|
|
353
|
+
target_debt = max(health["max_debt"], 0) * (1 - center / 100.0)
|
|
354
|
+
delta = target_debt - debt
|
|
355
|
+
|
|
356
|
+
if pct < low:
|
|
357
|
+
# De-lever: repay USDC
|
|
358
|
+
repay_amt = abs(delta)
|
|
359
|
+
if repay_amt < 1:
|
|
360
|
+
result["action"] = "none (repay too small)"
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
# Check cooldown (bypass under 20%)
|
|
364
|
+
cooldown_file = Path(state_dir) / f"last-action-delever"
|
|
365
|
+
if pct >= 20 and cooldown_file.exists():
|
|
366
|
+
last_act = int(cooldown_file.read_text().strip())
|
|
367
|
+
if time.time() - last_act < cooldown_secs:
|
|
368
|
+
result["action"] = "delever (cooldown)"
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
if repay_amt > raw_usdc:
|
|
372
|
+
# Need to withdraw from position first — escalate
|
|
373
|
+
write_escalation(state_dir, "repay-no-usdc", {
|
|
374
|
+
"reason": "repay_needs_position_close",
|
|
375
|
+
"repay_needed": repay_amt,
|
|
376
|
+
"raw_usdc": raw_usdc,
|
|
377
|
+
"health_pct": pct,
|
|
378
|
+
"label": label,
|
|
379
|
+
})
|
|
380
|
+
result["action"] = "escalate (need close)"
|
|
381
|
+
return result
|
|
382
|
+
|
|
383
|
+
if dry_run:
|
|
384
|
+
result["action"] = f"would repay ${repay_amt:.2f} USDC"
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
# Execute repay
|
|
388
|
+
try:
|
|
389
|
+
r = subprocess.run(
|
|
390
|
+
[sys.executable, tool_path, "repay", "--pool", "usdc",
|
|
391
|
+
"--amount", f"{repay_amt:.2f}", "--execute"],
|
|
392
|
+
capture_output=True, text=True, timeout=120,
|
|
393
|
+
)
|
|
394
|
+
if r.returncode == 0:
|
|
395
|
+
cooldown_file.write_text(str(int(time.time())))
|
|
396
|
+
result["action"] = f"repaid ${repay_amt:.2f}"
|
|
397
|
+
else:
|
|
398
|
+
result["error"] = f"repay failed: {r.stderr[:200]}"
|
|
399
|
+
except Exception as e:
|
|
400
|
+
result["error"] = f"repay error: {e}"
|
|
401
|
+
|
|
402
|
+
elif pct > high:
|
|
403
|
+
# Lever: borrow + deploy into position
|
|
404
|
+
borrow_amt = delta
|
|
405
|
+
if borrow_amt < 1:
|
|
406
|
+
result["action"] = "none (borrow too small)"
|
|
407
|
+
return result
|
|
408
|
+
|
|
409
|
+
cooldown_file = Path(state_dir) / f"last-action-lever"
|
|
410
|
+
if cooldown_file.exists():
|
|
411
|
+
last_act = int(cooldown_file.read_text().strip())
|
|
412
|
+
if time.time() - last_act < cooldown_secs:
|
|
413
|
+
result["action"] = "lever (cooldown)"
|
|
414
|
+
return result
|
|
415
|
+
|
|
416
|
+
if dry_run:
|
|
417
|
+
result["action"] = f"would borrow ${borrow_amt:.2f} and deploy"
|
|
418
|
+
return result
|
|
419
|
+
|
|
420
|
+
# Borrow
|
|
421
|
+
try:
|
|
422
|
+
r = subprocess.run(
|
|
423
|
+
[sys.executable, tool_path, "borrow", "--pool", "usdc",
|
|
424
|
+
"--amount", f"{borrow_amt:.2f}", "--execute"],
|
|
425
|
+
capture_output=True, text=True, timeout=120,
|
|
426
|
+
)
|
|
427
|
+
if r.returncode != 0:
|
|
428
|
+
result["error"] = f"borrow failed: {r.stderr[:200]}"
|
|
429
|
+
return result
|
|
430
|
+
except Exception as e:
|
|
431
|
+
result["error"] = f"borrow error: {e}"
|
|
432
|
+
return result
|
|
433
|
+
|
|
434
|
+
# Deploy into GMX (default position type)
|
|
435
|
+
if position_type == "gmx":
|
|
436
|
+
try:
|
|
437
|
+
r = subprocess.run(
|
|
438
|
+
[sys.executable, tool_path, "gmx-deposit",
|
|
439
|
+
"--market", market, "--amount", f"{borrow_amt:.2f}",
|
|
440
|
+
"--side", side, "--fee-buffer", "1.5", "--execute"],
|
|
441
|
+
capture_output=True, text=True, timeout=120,
|
|
442
|
+
)
|
|
443
|
+
if r.returncode == 0:
|
|
444
|
+
cooldown_file.write_text(str(int(time.time())))
|
|
445
|
+
result["action"] = f"borrowed ${borrow_amt:.2f} + GMX deposit"
|
|
446
|
+
else:
|
|
447
|
+
# Partial state: borrowed but deposit failed
|
|
448
|
+
result["warning"] = f"borrow ok but deposit failed: {r.stderr[:200]}"
|
|
449
|
+
result["action"] = "partial (borrowed, deposit failed)"
|
|
450
|
+
except Exception as e:
|
|
451
|
+
result["error"] = f"gmx deposit error: {e}"
|
|
452
|
+
else:
|
|
453
|
+
result["action"] = f"escalate (unsupported position: {position_type})"
|
|
454
|
+
write_escalation(state_dir, "unsupported-position", {
|
|
455
|
+
"reason": "lever_unsupported_position",
|
|
456
|
+
"position": position_type,
|
|
457
|
+
"borrow_amt": borrow_amt,
|
|
458
|
+
"label": label,
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
return result
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ════════════════════════════════════════════════════════════════════
|
|
465
|
+
# CLI entry point
|
|
466
|
+
# ════════════════════════════════════════════════════════════════════
|
|
467
|
+
|
|
468
|
+
def cli():
|
|
469
|
+
"""Entry point for `primecli health ...` subcommand."""
|
|
470
|
+
args = sys.argv[2:] if len(sys.argv) > 2 else []
|
|
471
|
+
# Default state/config paths
|
|
472
|
+
label = os.environ.get("PRIMECLI_LABEL", "prime")
|
|
473
|
+
config_base = os.environ.get(
|
|
474
|
+
"PRIMECLI_CONFIG_DIR",
|
|
475
|
+
os.path.expanduser(f"~/.primecli/{label}/"),
|
|
476
|
+
)
|
|
477
|
+
strategy_path = os.path.join(config_base, "strategy.json")
|
|
478
|
+
state_dir = os.path.join(config_base, "state")
|
|
479
|
+
|
|
480
|
+
subcmd = args[0] if args else "status"
|
|
481
|
+
|
|
482
|
+
# Resolve the tool path: same dir as the module that imports us
|
|
483
|
+
tool_path = os.environ.get("PRIMECLI_TOOL", "")
|
|
484
|
+
|
|
485
|
+
if subcmd == "status":
|
|
486
|
+
# Print current health state
|
|
487
|
+
if not tool_path:
|
|
488
|
+
print("PRIMECLI_TOOL not set. Pass --tool or set env.")
|
|
489
|
+
return
|
|
490
|
+
raw = subprocess.run(
|
|
491
|
+
[sys.executable, tool_path, "defi", "--json"],
|
|
492
|
+
capture_output=True, text=True, timeout=90,
|
|
493
|
+
)
|
|
494
|
+
tier = "premium" # default
|
|
495
|
+
try:
|
|
496
|
+
t = subprocess.run(
|
|
497
|
+
[sys.executable, tool_path, "prime-tier"],
|
|
498
|
+
capture_output=True, text=True, timeout=30,
|
|
499
|
+
)
|
|
500
|
+
if "premium" in t.stdout.lower():
|
|
501
|
+
tier = "premium"
|
|
502
|
+
elif "basic" in t.stdout.lower():
|
|
503
|
+
tier = "basic"
|
|
504
|
+
except Exception:
|
|
505
|
+
pass
|
|
506
|
+
max_mult = TIER_MAX.get(tier, 10)
|
|
507
|
+
if raw.returncode == 0:
|
|
508
|
+
health = compute_health(json.loads(raw.stdout), max_mult)
|
|
509
|
+
health["tier"] = tier
|
|
510
|
+
print(json.dumps(health, indent=2))
|
|
511
|
+
else:
|
|
512
|
+
print(f"Error: {raw.stderr[:200]}")
|
|
513
|
+
|
|
514
|
+
elif subcmd == "strategy":
|
|
515
|
+
# Show / configure strategy
|
|
516
|
+
strategy = load_strategy(strategy_path)
|
|
517
|
+
if strategy:
|
|
518
|
+
print(json.dumps(strategy, indent=2))
|
|
519
|
+
else:
|
|
520
|
+
print(f"No strategy at {strategy_path}")
|
|
521
|
+
|
|
522
|
+
elif subcmd == "monitor":
|
|
523
|
+
# Run one tick
|
|
524
|
+
if not tool_path:
|
|
525
|
+
print("PRIMECLI_TOOL not set.")
|
|
526
|
+
return
|
|
527
|
+
result = run_tick(
|
|
528
|
+
tool_path=tool_path,
|
|
529
|
+
strategy_path=strategy_path,
|
|
530
|
+
state_dir=state_dir,
|
|
531
|
+
label=label,
|
|
532
|
+
dry_run="--dry-run" in args,
|
|
533
|
+
)
|
|
534
|
+
print(json.dumps(result, indent=2, default=str))
|
|
535
|
+
|
|
536
|
+
else:
|
|
537
|
+
print(f"Unknown health subcommand: {subcmd}")
|
|
538
|
+
print("Usage: primecli health [status|strategy|monitor] [--dry-run]")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: primecli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Agent-friendly CLI tools for the DeltaPrime (Avalanche) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required.
|
|
5
5
|
Author: Mnemosyne-quest contributors
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "primecli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.7"
|
|
8
8
|
description = "Agent-friendly CLI tools for the DeltaPrime (Avalanche) and DegenPrime (Base) lending and leverage protocols. Preview-by-default; no Etherscan key required."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|