primecli 0.2.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: primecli
3
- Version: 0.2.5
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
@@ -254,6 +254,36 @@ If your failure is not on this list and the on-chain revert reason is opaque, ca
254
254
  - Per-version release notes: <https://github.com/Mnemosyne-quest/primecli/releases>.
255
255
  - The `main` branch may be ahead of the latest tagged release; install from git if you need the bleeding edge.
256
256
 
257
+ ## Health Math (0-100% Scale)
258
+
259
+ The protocol's health meter runs from 0% (liquidation) to 100% (no debt).
260
+ The tool reports `health_ratio` from the SolvencyFacet (1.0 = liquidation line),
261
+ but the frontend-friendly 0-100% scale is computed as:
262
+
263
+ ```
264
+ equity = total_supplied_usd - total_debt_usd
265
+ max_debt = equity * (tier - 1) # PREMIUM=10, BASIC=5
266
+ health_pct = (max_debt - debt) / max_debt * 100
267
+ ```
268
+
269
+ Key insight: **LP tokens (GMX, LB, Aerodrome CL) don't count as full-rate
270
+ collateral.** Using gross `supplied_usd` as the borrowing base inflates `max_debt`
271
+ and overstates health. The formula above uses `equity` only, which matches the
272
+ protocol's cross-margin calculation within ~3pp.
273
+
274
+ ### Rebalancing
275
+
276
+ Auto-rebalance within a configurable range (e.g. 30-70%) by borrowing more
277
+ (deploy into existing position) or repaying debt. A 1h cooldown prevents
278
+ frequent toggling. Below 20% the cooldown is bypassed and the agent is escalated.
279
+
280
+ ### Stop Loss (Equity Drawdown)
281
+
282
+ Track a baseline equity (recorded at position open) and trigger a full close
283
+ if equity drops X% below that baseline. Withdraw from the position, swap to
284
+ USDC, repay all debt. See `examples/health-monitoring/` for a reference
285
+ implementation.
286
+
257
287
  ## Contributing
258
288
 
259
289
  PRs welcome. Open an issue first if you are planning anything non-trivial (new facet support, new chain, write paths for Aerodrome). Pinning ABIs and verifying on-chain shapes takes a real probe pass, and it is worth aligning before doing the work.
@@ -222,6 +222,36 @@ If your failure is not on this list and the on-chain revert reason is opaque, ca
222
222
  - Per-version release notes: <https://github.com/Mnemosyne-quest/primecli/releases>.
223
223
  - The `main` branch may be ahead of the latest tagged release; install from git if you need the bleeding edge.
224
224
 
225
+ ## Health Math (0-100% Scale)
226
+
227
+ The protocol's health meter runs from 0% (liquidation) to 100% (no debt).
228
+ The tool reports `health_ratio` from the SolvencyFacet (1.0 = liquidation line),
229
+ but the frontend-friendly 0-100% scale is computed as:
230
+
231
+ ```
232
+ equity = total_supplied_usd - total_debt_usd
233
+ max_debt = equity * (tier - 1) # PREMIUM=10, BASIC=5
234
+ health_pct = (max_debt - debt) / max_debt * 100
235
+ ```
236
+
237
+ Key insight: **LP tokens (GMX, LB, Aerodrome CL) don't count as full-rate
238
+ collateral.** Using gross `supplied_usd` as the borrowing base inflates `max_debt`
239
+ and overstates health. The formula above uses `equity` only, which matches the
240
+ protocol's cross-margin calculation within ~3pp.
241
+
242
+ ### Rebalancing
243
+
244
+ Auto-rebalance within a configurable range (e.g. 30-70%) by borrowing more
245
+ (deploy into existing position) or repaying debt. A 1h cooldown prevents
246
+ frequent toggling. Below 20% the cooldown is bypassed and the agent is escalated.
247
+
248
+ ### Stop Loss (Equity Drawdown)
249
+
250
+ Track a baseline equity (recorded at position open) and trigger a full close
251
+ if equity drops X% below that baseline. Withdraw from the position, swap to
252
+ USDC, repay all debt. See `examples/health-monitoring/` for a reference
253
+ implementation.
254
+
225
255
  ## Contributing
226
256
 
227
257
  PRs welcome. Open an issue first if you are planning anything non-trivial (new facet support, new chain, write paths for Aerodrome). Pinning ABIs and verifying on-chain shapes takes a real probe pass, and it is worth aligning before doing the work.
@@ -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.5
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
@@ -254,6 +254,36 @@ If your failure is not on this list and the on-chain revert reason is opaque, ca
254
254
  - Per-version release notes: <https://github.com/Mnemosyne-quest/primecli/releases>.
255
255
  - The `main` branch may be ahead of the latest tagged release; install from git if you need the bleeding edge.
256
256
 
257
+ ## Health Math (0-100% Scale)
258
+
259
+ The protocol's health meter runs from 0% (liquidation) to 100% (no debt).
260
+ The tool reports `health_ratio` from the SolvencyFacet (1.0 = liquidation line),
261
+ but the frontend-friendly 0-100% scale is computed as:
262
+
263
+ ```
264
+ equity = total_supplied_usd - total_debt_usd
265
+ max_debt = equity * (tier - 1) # PREMIUM=10, BASIC=5
266
+ health_pct = (max_debt - debt) / max_debt * 100
267
+ ```
268
+
269
+ Key insight: **LP tokens (GMX, LB, Aerodrome CL) don't count as full-rate
270
+ collateral.** Using gross `supplied_usd` as the borrowing base inflates `max_debt`
271
+ and overstates health. The formula above uses `equity` only, which matches the
272
+ protocol's cross-margin calculation within ~3pp.
273
+
274
+ ### Rebalancing
275
+
276
+ Auto-rebalance within a configurable range (e.g. 30-70%) by borrowing more
277
+ (deploy into existing position) or repaying debt. A 1h cooldown prevents
278
+ frequent toggling. Below 20% the cooldown is bypassed and the agent is escalated.
279
+
280
+ ### Stop Loss (Equity Drawdown)
281
+
282
+ Track a baseline equity (recorded at position open) and trigger a full close
283
+ if equity drops X% below that baseline. Withdraw from the position, swap to
284
+ USDC, repay all debt. See `examples/health-monitoring/` for a reference
285
+ implementation.
286
+
257
287
  ## Contributing
258
288
 
259
289
  PRs welcome. Open an issue first if you are planning anything non-trivial (new facet support, new chain, write paths for Aerodrome). Pinning ABIs and verifying on-chain shapes takes a real probe pass, and it is worth aligning before doing the work.
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  primecli/__init__.py
5
5
  primecli/degenprime.py
6
6
  primecli/deltaprime.py
7
+ primecli/health_monitor.py
7
8
  primecli.egg-info/PKG-INFO
8
9
  primecli.egg-info/SOURCES.txt
9
10
  primecli.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "primecli"
7
- version = "0.2.5"
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