wayfinder-paths 0.1.30__py3-none-any.whl → 0.1.31__py3-none-any.whl

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.

Potentially problematic release.


This version of wayfinder-paths might be problematic. Click here for more details.

Files changed (32) hide show
  1. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +15 -1
  2. wayfinder_paths/adapters/hyperliquid_adapter/exchange.py +3 -3
  3. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1 -1
  4. wayfinder_paths/adapters/hyperliquid_adapter/test_cancel_order.py +57 -0
  5. wayfinder_paths/adapters/hyperliquid_adapter/test_exchange_mid_prices.py +52 -0
  6. wayfinder_paths/adapters/hyperliquid_adapter/test_hyperliquid_sdk_live.py +64 -0
  7. wayfinder_paths/adapters/hyperliquid_adapter/util.py +9 -10
  8. wayfinder_paths/core/clients/PoolClient.py +1 -1
  9. wayfinder_paths/mcp/tools/execute.py +48 -16
  10. wayfinder_paths/mcp/tools/hyperliquid.py +1 -13
  11. wayfinder_paths/mcp/tools/quotes.py +42 -7
  12. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +24 -0
  13. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +248 -27
  14. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +125 -15
  15. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +29 -0
  16. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/manifest.yaml +33 -0
  17. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +23 -0
  18. wayfinder_paths/tests/test_manifests.py +93 -0
  19. wayfinder_paths/tests/test_mcp_balances.py +73 -0
  20. wayfinder_paths/tests/test_mcp_discovery.py +34 -0
  21. wayfinder_paths/tests/test_mcp_execute.py +146 -0
  22. wayfinder_paths/tests/test_mcp_hyperliquid_execute.py +69 -0
  23. wayfinder_paths/tests/test_mcp_idempotency_store.py +14 -0
  24. wayfinder_paths/tests/test_mcp_quote_swap.py +60 -0
  25. wayfinder_paths/tests/test_mcp_run_script.py +47 -0
  26. wayfinder_paths/tests/test_mcp_tokens.py +49 -0
  27. wayfinder_paths/tests/test_mcp_utils.py +35 -0
  28. wayfinder_paths/tests/test_mcp_wallets.py +38 -0
  29. {wayfinder_paths-0.1.30.dist-info → wayfinder_paths-0.1.31.dist-info}/METADATA +2 -2
  30. {wayfinder_paths-0.1.30.dist-info → wayfinder_paths-0.1.31.dist-info}/RECORD +32 -15
  31. {wayfinder_paths-0.1.30.dist-info → wayfinder_paths-0.1.31.dist-info}/WHEEL +1 -1
  32. {wayfinder_paths-0.1.30.dist-info → wayfinder_paths-0.1.31.dist-info}/LICENSE +0 -0
@@ -515,11 +515,14 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
515
515
  f"Found ${strategy_usdc:.2f} USDC in strategy wallet, bridging to Hyperliquid"
516
516
  )
517
517
 
518
+ # Convert USDC to raw units (6 decimals)
519
+ usdc_raw = int(strategy_usdc * 1e6)
518
520
  success, result = await self.balance_adapter.send_to_address(
519
521
  token_id=USDC_ARBITRUM_TOKEN_ID,
520
- amount=strategy_usdc,
522
+ amount=usdc_raw,
521
523
  from_wallet=strategy_wallet,
522
524
  to_address=HYPERLIQUID_BRIDGE,
525
+ signing_callback=self.strategy_wallet_signing_callback,
523
526
  )
524
527
 
525
528
  if not success:
@@ -559,7 +562,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
559
562
  self.logger.warning(f"Failed to bridge USDC to Hyperliquid: {e}")
560
563
 
561
564
  if self.current_position is None:
562
- return await self._find_and_open_position()
565
+ return await self._find_and_open_position(rotation_reason="initial_open")
563
566
 
564
567
  # Monitor existing position (handles idle capital, leg balance, stop-loss)
565
568
  return await self._monitor_position()
@@ -1029,7 +1032,9 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1029
1032
  # Position Management #
1030
1033
  # ------------------------------------------------------------------ #
1031
1034
 
1032
- async def _find_and_open_position(self) -> StatusTuple:
1035
+ async def _find_and_open_position(
1036
+ self, *, rotation_reason: str | None = None
1037
+ ) -> StatusTuple:
1033
1038
  self.logger.info("Analyzing basis trading opportunities...")
1034
1039
 
1035
1040
  try:
@@ -1255,6 +1260,19 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1255
1260
  entry_timestamp=int(time.time() * 1000),
1256
1261
  )
1257
1262
 
1263
+ try:
1264
+ await self._record_rotation(
1265
+ coin=coin,
1266
+ spot_asset_id=int(spot_asset_id),
1267
+ perp_asset_id=int(perp_asset_id),
1268
+ spot_units=float(spot_filled),
1269
+ perp_units=float(perp_filled),
1270
+ leverage=int(leverage),
1271
+ reason=rotation_reason,
1272
+ )
1273
+ except Exception as exc: # noqa: BLE001
1274
+ self.logger.debug(f"Failed to record rotation: {exc}")
1275
+
1258
1276
  return (
1259
1277
  True,
1260
1278
  f"Opened basis position on {coin}: {spot_filled:.4f} units at {leverage}x, expected net APY: {expected_net_apy_pct:.1f}%",
@@ -1268,6 +1286,128 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1268
1286
  # Position Scaling #
1269
1287
  # ------------------------------------------------------------------ #
1270
1288
 
1289
+ async def _unused_usd_now(
1290
+ self,
1291
+ state: dict[str, Any],
1292
+ *,
1293
+ mid_prices: dict[str, Any] | None = None,
1294
+ ) -> tuple[float, float]:
1295
+ """Estimate deployable idle USDC without mistaking perp PnL for new cash.
1296
+
1297
+ `marginSummary.withdrawable` can increase just because the perp leg moves in our favor
1298
+ (unrealized PnL), which frees margin but does not represent a fresh cash deposit.
1299
+ This helper estimates idle capital by comparing current bankroll to what's allocated
1300
+ to the delta-neutral position.
1301
+
1302
+ Returns:
1303
+ - unused_usd: estimated deployable idle USDC.
1304
+ - bankroll_now: estimated bankroll backing the position (perp equity + spot value + spot USDC).
1305
+ """
1306
+ if self.current_position is None:
1307
+ return 0.0, 0.0
1308
+
1309
+ coin = self.current_position.coin
1310
+
1311
+ perp_position = self._get_perp_position(state)
1312
+ if perp_position is None:
1313
+ return 0.0, 0.0
1314
+
1315
+ if mid_prices is None:
1316
+ success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
1317
+ if not success:
1318
+ return 0.0, 0.0
1319
+ mid_prices = mids
1320
+
1321
+ current_price = float(self._resolve_mid_price(coin, mid_prices) or 0.0)
1322
+ if current_price <= 0:
1323
+ return 0.0, 0.0
1324
+
1325
+ # Spot side: only track the strategy coin + USDC. (Other balances are ignored for safety.)
1326
+ address = self._get_strategy_wallet_address()
1327
+ spot_units = 0.0
1328
+ spot_usdc = 0.0
1329
+ success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
1330
+ address
1331
+ )
1332
+ if success:
1333
+ for bal in spot_state.get("balances", []):
1334
+ bal_coin = str(bal.get("coin", ""))
1335
+ try:
1336
+ total = float(bal.get("total", 0) or 0.0)
1337
+ except (TypeError, ValueError):
1338
+ continue
1339
+
1340
+ if bal_coin == "USDC":
1341
+ spot_usdc = total
1342
+ elif self._coins_match(bal_coin, coin):
1343
+ spot_units = abs(total)
1344
+
1345
+ spot_value = spot_units * current_price
1346
+
1347
+ # Perp side (account value includes margin + unrealized PnL + funding).
1348
+ margin_summary = state.get("marginSummary") or {}
1349
+ try:
1350
+ perp_equity = float(margin_summary.get("accountValue", 0) or 0.0)
1351
+ except (TypeError, ValueError):
1352
+ perp_equity = 0.0
1353
+
1354
+ bankroll_now = perp_equity + spot_value + spot_usdc
1355
+
1356
+ # Position sizing inputs.
1357
+ try:
1358
+ perp_units = abs(float(perp_position.get("szi", 0) or 0.0))
1359
+ except (TypeError, ValueError):
1360
+ perp_units = 0.0
1361
+
1362
+ if perp_units <= 0 or spot_units <= 0:
1363
+ return 0.0, bankroll_now
1364
+
1365
+ entry_px_raw = perp_position.get("entryPx") or self.current_position.entry_price
1366
+ try:
1367
+ perp_entry_price = float(entry_px_raw or 0.0)
1368
+ except (TypeError, ValueError):
1369
+ perp_entry_price = 0.0
1370
+
1371
+ if perp_entry_price <= 0:
1372
+ return 0.0, bankroll_now
1373
+
1374
+ leverage = float(
1375
+ self.current_position.leverage or self.DEFAULT_MAX_LEVERAGE or 1
1376
+ )
1377
+ lev_raw = perp_position.get("leverage")
1378
+ if isinstance(lev_raw, dict):
1379
+ try:
1380
+ leverage = float(lev_raw.get("value") or leverage)
1381
+ except (TypeError, ValueError):
1382
+ pass
1383
+ elif lev_raw is not None:
1384
+ try:
1385
+ leverage = float(lev_raw)
1386
+ except (TypeError, ValueError):
1387
+ pass
1388
+ if leverage <= 0:
1389
+ leverage = 1.0
1390
+
1391
+ funding_since_open = 0.0
1392
+ cum_funding = perp_position.get("cumFunding") or {}
1393
+ if isinstance(cum_funding, dict):
1394
+ try:
1395
+ funding_since_open = abs(float(cum_funding.get("sinceOpen", 0) or 0.0))
1396
+ except (TypeError, ValueError):
1397
+ funding_since_open = 0.0
1398
+
1399
+ # Margin+PnL contribution of the perp leg for a short at leverage L.
1400
+ current_perp_contrib = (
1401
+ perp_entry_price * (1.0 + 1.0 / leverage) - current_price
1402
+ ) * perp_units
1403
+ # Conservative guard: don't let extreme loss create fake "unused" capital.
1404
+ current_perp_contrib = max(0.0, current_perp_contrib)
1405
+
1406
+ allocated_now = current_perp_contrib + spot_value + funding_since_open
1407
+ unused_usd = max(0.0, bankroll_now - allocated_now)
1408
+
1409
+ return unused_usd, bankroll_now
1410
+
1271
1411
  async def _get_undeployed_capital(self) -> tuple[float, float]:
1272
1412
  address = self._get_strategy_wallet_address()
1273
1413
 
@@ -1495,7 +1635,9 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1495
1635
  False,
1496
1636
  f"Emergency rebalance failed - could not close: {close_msg}",
1497
1637
  )
1498
- return await self._find_and_open_position()
1638
+ return await self._find_and_open_position(
1639
+ rotation_reason="near_liquidation"
1640
+ )
1499
1641
 
1500
1642
  needs_rebalance, reason = await self._needs_new_position(state, hl_value)
1501
1643
 
@@ -1514,7 +1656,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1514
1656
  if not close_success:
1515
1657
  return (False, f"Rebalance failed - could not close: {close_msg}")
1516
1658
 
1517
- return await self._find_and_open_position()
1659
+ return await self._find_and_open_position(rotation_reason=str(reason))
1518
1660
 
1519
1661
  leg_ok, leg_msg = await self._verify_leg_balance(state)
1520
1662
  if not leg_ok:
@@ -1525,15 +1667,14 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1525
1667
  else:
1526
1668
  actions_taken.append(f"Leg imbalance repair failed: {repair_msg}")
1527
1669
 
1528
- perp_margin, spot_usdc = await self._get_undeployed_capital()
1529
- total_idle = perp_margin + spot_usdc
1530
- min_deploy = max(self.MIN_UNUSED_USD, self.UNUSED_REL_EPS * self.deposit_amount)
1670
+ unused_usd, bankroll_now = await self._unused_usd_now(state)
1671
+ min_deploy = max(self.MIN_UNUSED_USD, self.UNUSED_REL_EPS * bankroll_now)
1531
1672
 
1532
- if total_idle > min_deploy:
1673
+ if unused_usd > min_deploy:
1533
1674
  self.logger.info(
1534
- f"Found ${total_idle:.2f} idle capital, scaling up position"
1675
+ f"Found ${unused_usd:.2f} deployable idle USDC, scaling up position"
1535
1676
  )
1536
- scale_ok, scale_msg = await self._scale_up_position(total_idle)
1677
+ scale_ok, scale_msg = await self._scale_up_position(unused_usd)
1537
1678
  if scale_ok:
1538
1679
  actions_taken.append(f"Scaled up: {scale_msg}")
1539
1680
  # Refresh state after scale-up so stop-loss uses new position size/liq price
@@ -1550,16 +1691,23 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1550
1691
  actions_taken.append(sl_msg)
1551
1692
 
1552
1693
  position_age_hours = (time.time() * 1000 - pos.entry_timestamp) / (1000 * 3600)
1694
+ rotation_hint = ""
1695
+ try:
1696
+ rotation_hint = await self._rotation_cooldown_hint()
1697
+ except Exception as exc: # noqa: BLE001
1698
+ self.logger.debug(f"Could not compute rotation cooldown hint: {exc}")
1553
1699
 
1554
1700
  if actions_taken:
1701
+ suffix = f". Rotation: {rotation_hint}" if rotation_hint else ""
1555
1702
  return (
1556
1703
  True,
1557
- f"Position on {coin} monitored, age: {position_age_hours:.1f}h. Actions: {'; '.join(actions_taken)}",
1704
+ f"Position on {coin} monitored, age: {position_age_hours:.1f}h. Actions: {'; '.join(actions_taken)}{suffix}",
1558
1705
  )
1559
1706
 
1707
+ suffix = f". Rotation: {rotation_hint}" if rotation_hint else ""
1560
1708
  return (
1561
1709
  True,
1562
- f"Position on {coin} healthy, age: {position_age_hours:.1f}h",
1710
+ f"Position on {coin} healthy, age: {position_age_hours:.1f}h{suffix}",
1563
1711
  )
1564
1712
 
1565
1713
  async def _is_near_liquidation(self, state: dict[str, Any]) -> tuple[bool, str]:
@@ -2128,6 +2276,44 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
2128
2276
  # Rotation Cooldown #
2129
2277
  # ------------------------------------------------------------------ #
2130
2278
 
2279
+ async def _record_rotation(
2280
+ self,
2281
+ *,
2282
+ coin: str,
2283
+ spot_asset_id: int,
2284
+ perp_asset_id: int,
2285
+ spot_units: float,
2286
+ perp_units: float,
2287
+ leverage: int,
2288
+ reason: str | None = None,
2289
+ ) -> None:
2290
+ """Write a rotation marker to the local ledger so cooldown survives restarts."""
2291
+ if not self.ledger_adapter:
2292
+ return
2293
+
2294
+ wallet_address = self._get_strategy_wallet_address()
2295
+ op_data: dict[str, Any] = {
2296
+ "type": "BASIS_ROTATION",
2297
+ "coin": str(coin),
2298
+ "spot_asset_id": int(spot_asset_id),
2299
+ "perp_asset_id": int(perp_asset_id),
2300
+ "spot_units": float(spot_units),
2301
+ "perp_units": float(perp_units),
2302
+ "leverage": int(leverage),
2303
+ }
2304
+ if reason:
2305
+ op_data["reason"] = str(reason)
2306
+
2307
+ try:
2308
+ await self.ledger_adapter.ledger_client.add_strategy_operation(
2309
+ wallet_address=wallet_address,
2310
+ operation_data=op_data,
2311
+ usd_value="0",
2312
+ strategy_name=self.name or "basis_trading_strategy",
2313
+ )
2314
+ except Exception as exc: # noqa: BLE001
2315
+ self.logger.debug(f"Failed to write BASIS_ROTATION to ledger: {exc}")
2316
+
2131
2317
  async def _get_last_rotation_time(self) -> datetime | None:
2132
2318
  wallet_address = self._get_strategy_wallet_address()
2133
2319
 
@@ -2145,16 +2331,20 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
2145
2331
  else []
2146
2332
  )
2147
2333
  for txn in tx_list:
2148
- op_data = txn.get("op_data", {})
2149
- if (
2150
- op_data.get("type") == "HYPE_SPOT"
2151
- and op_data.get("buy_or_sell") == "buy"
2152
- ):
2153
- created_str = txn.get("created")
2154
- if created_str:
2155
- return datetime.fromisoformat(
2156
- str(created_str).replace("Z", "+00:00")
2157
- )
2334
+ op_data = txn.get("op_data") or {}
2335
+ op_type = str(
2336
+ txn.get("operation")
2337
+ or (op_data.get("type") if isinstance(op_data, dict) else "")
2338
+ or ""
2339
+ )
2340
+ if op_type != "BASIS_ROTATION":
2341
+ continue
2342
+
2343
+ created_str = txn.get("created") or txn.get("timestamp")
2344
+ if created_str:
2345
+ return datetime.fromisoformat(
2346
+ str(created_str).replace("Z", "+00:00")
2347
+ )
2158
2348
 
2159
2349
  return None
2160
2350
  except Exception as e:
@@ -2174,14 +2364,45 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
2174
2364
  if last_rotation.tzinfo is None:
2175
2365
  last_rotation = last_rotation.replace(tzinfo=UTC)
2176
2366
 
2177
- elapsed = now - last_rotation
2178
2367
  cooldown = timedelta(days=self.ROTATION_MIN_INTERVAL_DAYS)
2179
2368
 
2180
- if elapsed >= cooldown:
2369
+ unlock_at = last_rotation + cooldown
2370
+ remaining = unlock_at - now
2371
+
2372
+ if remaining <= timedelta(0):
2181
2373
  return True, "Cooldown passed"
2182
2374
 
2183
- remaining = cooldown - elapsed
2184
- return False, f"Rotation cooldown: {remaining.days} days remaining"
2375
+ days = remaining.days
2376
+ hours = remaining.seconds // 3600
2377
+ unlock_str = unlock_at.strftime("%Y-%m-%d %H:%M UTC")
2378
+ return (
2379
+ False,
2380
+ f"Rotation cooldown: {days}d {hours}h remaining (unlocks {unlock_str})",
2381
+ )
2382
+
2383
+ async def _rotation_cooldown_hint(self) -> str:
2384
+ """Human-readable rotation cooldown status for update() output."""
2385
+ if self.current_position is None:
2386
+ return ""
2387
+
2388
+ last_rotation = await self._get_last_rotation_time()
2389
+ if last_rotation is None:
2390
+ return "unlocked (cooldown inactive; no rotation recorded)"
2391
+
2392
+ now = datetime.now(UTC)
2393
+ if last_rotation.tzinfo is None:
2394
+ last_rotation = last_rotation.replace(tzinfo=UTC)
2395
+
2396
+ cooldown = timedelta(days=self.ROTATION_MIN_INTERVAL_DAYS)
2397
+ unlock_at = last_rotation + cooldown
2398
+ remaining = unlock_at - now
2399
+ if remaining <= timedelta(0):
2400
+ return "unlocked (cooldown passed)"
2401
+
2402
+ days = remaining.days
2403
+ hours = remaining.seconds // 3600
2404
+ unlock_str = unlock_at.strftime("%Y-%m-%d %H:%M UTC")
2405
+ return f"{days}d {hours}h remaining (unlocks {unlock_str})"
2185
2406
 
2186
2407
  # ------------------------------------------------------------------ #
2187
2408
  # Live Portfolio Value #
@@ -429,35 +429,34 @@ class TestBasisTradingStrategy:
429
429
  coin="ETH",
430
430
  spot_asset_id=10000,
431
431
  perp_asset_id=1,
432
- spot_amount=1.0,
433
- perp_amount=1.0,
432
+ spot_amount=0.03,
433
+ perp_amount=0.03,
434
434
  entry_price=2000.0,
435
435
  leverage=2,
436
436
  entry_timestamp=1700000000000,
437
437
  funding_collected=0.0,
438
438
  )
439
439
 
440
- # Mock user state with perp position and idle capital
441
- # totalNtlPos represents position notional value, set high to avoid rebalance trigger
442
- # unused_usd = accountValue - totalNtlPos = 120 - 112 = 8
443
- # threshold for rebalance = epsilon * 2 = max(5, 0.02 * 100) * 2 = 10
444
- # 8 < 10 so no rebalance
445
- # total_idle = withdrawable (12) + spot_usdc (8) = 20 > min_deploy (5) so will scale up
446
- # order_usd = 20 * (2/3) = 13.33 > MIN_NOTIONAL_USD (10)
440
+ # Make sure there's deployable idle USDC without relying on marginSummary.withdrawable.
441
+ # With 2x leverage and ~0.03 ETH:
442
+ # - spot value 0.03 * 2000 = 60
443
+ # - perp contrib (2000*(1+1/2) - 2000) * 0.03 = 30
444
+ # - bankroll 30 + 60 + 20 = 110
445
+ # - allocated 30 + 60 = 90
446
+ # - unused 20 (deployable USDC) -> scale up
447
447
  mock_hyperliquid_adapter.get_user_state = AsyncMock(
448
448
  return_value=(
449
449
  True,
450
450
  {
451
451
  "marginSummary": {
452
- "accountValue": "120",
452
+ "accountValue": "30",
453
453
  "withdrawable": "12",
454
- "totalNtlPos": "112",
455
454
  },
456
455
  "assetPositions": [
457
456
  {
458
457
  "position": {
459
458
  "coin": "ETH",
460
- "szi": "-1.0",
459
+ "szi": "-0.03",
461
460
  "leverage": {"value": "2"},
462
461
  "liquidationPx": "2500",
463
462
  "entryPx": "2000",
@@ -467,14 +466,14 @@ class TestBasisTradingStrategy:
467
466
  },
468
467
  )
469
468
  )
470
- # Include ETH spot balance for leg balance check, plus USDC for idle capital
469
+ # Include ETH spot balance for leg balance check, plus USDC to deploy.
471
470
  mock_hyperliquid_adapter.get_spot_user_state = AsyncMock(
472
471
  return_value=(
473
472
  True,
474
473
  {
475
474
  "balances": [
476
- {"coin": "ETH", "total": "1.0"},
477
- {"coin": "USDC", "total": "8"},
475
+ {"coin": "ETH", "total": "0.03"},
476
+ {"coin": "USDC", "total": "20"},
478
477
  ]
479
478
  },
480
479
  )
@@ -516,6 +515,117 @@ class TestBasisTradingStrategy:
516
515
  assert mock_filler.fill_pair_units.called
517
516
  assert success
518
517
 
518
+ @pytest.mark.asyncio
519
+ async def test_update_does_not_scale_on_perp_pnl_margin_release(
520
+ self, strategy, mock_hyperliquid_adapter
521
+ ):
522
+ """A favorable perp move can increase withdrawable margin; it should not trigger scale-up."""
523
+ strategy.deposit_amount = 100.0
524
+ strategy.current_position = BasisPosition(
525
+ coin="ETH",
526
+ spot_asset_id=10000,
527
+ perp_asset_id=1,
528
+ spot_amount=0.03,
529
+ perp_amount=0.03,
530
+ entry_price=2000.0,
531
+ leverage=2,
532
+ entry_timestamp=1700000000000,
533
+ funding_collected=0.0,
534
+ )
535
+
536
+ # Price down benefits the short perp; withdrawable may rise, but unused cash is ~0.
537
+ mock_hyperliquid_adapter.get_all_mid_prices = AsyncMock(
538
+ return_value=(True, {"ETH": 1800.0, "BTC": 50000.0})
539
+ )
540
+ mock_hyperliquid_adapter.get_user_state = AsyncMock(
541
+ return_value=(
542
+ True,
543
+ {
544
+ "marginSummary": {
545
+ "accountValue": "36",
546
+ "withdrawable": "12",
547
+ },
548
+ "assetPositions": [
549
+ {
550
+ "position": {
551
+ "coin": "ETH",
552
+ "szi": "-0.03",
553
+ "leverage": {"value": "2"},
554
+ "liquidationPx": "2500",
555
+ "entryPx": "2000",
556
+ }
557
+ }
558
+ ],
559
+ },
560
+ )
561
+ )
562
+ mock_hyperliquid_adapter.get_spot_user_state = AsyncMock(
563
+ return_value=(True, {"balances": [{"coin": "ETH", "total": "0.03"}]})
564
+ )
565
+
566
+ strategy._scale_up_position = AsyncMock(return_value=(True, "scaled"))
567
+
568
+ success, _ = await strategy.update()
569
+ assert success
570
+ strategy._scale_up_position.assert_not_awaited()
571
+
572
+ @pytest.mark.asyncio
573
+ async def test_update_includes_rotation_cooldown_hint(
574
+ self, strategy, mock_hyperliquid_adapter
575
+ ):
576
+ strategy.deposit_amount = 100.0
577
+ strategy.current_position = BasisPosition(
578
+ coin="ETH",
579
+ spot_asset_id=10000,
580
+ perp_asset_id=1,
581
+ spot_amount=0.03,
582
+ perp_amount=0.03,
583
+ entry_price=2000.0,
584
+ leverage=2,
585
+ entry_timestamp=1700000000000,
586
+ funding_collected=0.0,
587
+ )
588
+
589
+ mock_hyperliquid_adapter.get_user_state = AsyncMock(
590
+ return_value=(
591
+ True,
592
+ {
593
+ "marginSummary": {
594
+ "accountValue": "0",
595
+ "withdrawable": "0",
596
+ },
597
+ "assetPositions": [
598
+ {
599
+ "position": {
600
+ "coin": "ETH",
601
+ "szi": "-0.03",
602
+ "leverage": {"value": "2"},
603
+ "liquidationPx": "2500",
604
+ "entryPx": "2000",
605
+ }
606
+ }
607
+ ],
608
+ },
609
+ )
610
+ )
611
+ mock_hyperliquid_adapter.get_spot_user_state = AsyncMock(
612
+ return_value=(True, {"balances": [{"coin": "ETH", "total": "0.03"}]})
613
+ )
614
+
615
+ strategy._needs_new_position = AsyncMock(
616
+ return_value=(False, "Position healthy")
617
+ )
618
+ strategy._verify_leg_balance = AsyncMock(return_value=(True, "ok"))
619
+ strategy._unused_usd_now = AsyncMock(return_value=(0.0, 100.0))
620
+ strategy._ensure_stop_loss_valid = AsyncMock(
621
+ return_value=(True, "Stop-loss ok")
622
+ )
623
+ strategy._rotation_cooldown_hint = AsyncMock(return_value="3d 4h remaining")
624
+
625
+ success, msg = await strategy.update()
626
+ assert success
627
+ assert "Rotation: 3d 4h remaining" in msg
628
+
519
629
  @pytest.mark.asyncio
520
630
  async def test_ensure_builder_fee_approved_already_approved(
521
631
  self, mock_hyperliquid_adapter, ledger_adapter
@@ -0,0 +1,29 @@
1
+ schema_version: "0.1"
2
+ entrypoint: "wayfinder_paths.strategies.hyperlend_stable_yield_strategy.strategy.HyperlendStableYieldStrategy"
3
+ permissions:
4
+ policy: |
5
+ (wallet.id == 'FORMAT_WALLET_ID') AND (
6
+ # Allow HyperLend supply and withdraw
7
+ (action.type == 'hyperlend_supply') OR
8
+ (action.type == 'hyperlend_withdraw') OR
9
+ # Allow WHYPE wrap/unwrap
10
+ (action.type == 'whype_deposit') OR
11
+ (action.type == 'whype_withdraw') OR
12
+ # Allow swaps via supported routers (Enso, LiFi, PRJX)
13
+ (action.type == 'swap' AND action.chain_id == 999) OR
14
+ # Allow ERC20 approvals for routers
15
+ (action.type == 'erc20_approve') OR
16
+ # Allow withdrawals to main wallet
17
+ (action.type == 'erc20_transfer' AND action.to == main_wallet.address)
18
+ )
19
+ adapters:
20
+ - name: "BALANCE"
21
+ capabilities: ["wallet_read", "wallet_transfer"]
22
+ - name: "LEDGER"
23
+ capabilities: ["ledger.read", "ledger.write", "strategy.transactions"]
24
+ - name: "TOKEN"
25
+ capabilities: ["token.read"]
26
+ - name: "HYPERLEND"
27
+ capabilities: ["market.read", "supply", "withdraw", "rates"]
28
+ - name: "BRAP"
29
+ capabilities: ["swap.quote", "swap.execute"]
@@ -0,0 +1,33 @@
1
+ schema_version: "0.1"
2
+ entrypoint: "wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.MoonwellWstethLoopStrategy"
3
+ permissions:
4
+ policy: |
5
+ (wallet.id == 'FORMAT_WALLET_ID') AND (
6
+ # Allow Moonwell lending operations
7
+ (action.type == 'moonwell_mint') OR
8
+ (action.type == 'moonwell_redeem') OR
9
+ (action.type == 'moonwell_borrow') OR
10
+ (action.type == 'moonwell_repay') OR
11
+ (action.type == 'moonwell_enter_markets') OR
12
+ (action.type == 'moonwell_claim_rewards') OR
13
+ # Allow WETH wrap/unwrap
14
+ (action.type == 'weth_deposit') OR
15
+ (action.type == 'weth_withdraw') OR
16
+ # Allow swaps via Enso router
17
+ (action.type == 'swap' AND action.chain_id == 8453) OR
18
+ # Allow ERC20 approvals
19
+ (action.type == 'erc20_approve') OR
20
+ # Allow withdrawals to main wallet
21
+ (action.type == 'erc20_transfer' AND action.to == main_wallet.address)
22
+ )
23
+ adapters:
24
+ - name: "BALANCE"
25
+ capabilities: ["wallet_read", "wallet_transfer"]
26
+ - name: "LEDGER"
27
+ capabilities: ["ledger.read", "ledger.write", "strategy.transactions"]
28
+ - name: "TOKEN"
29
+ capabilities: ["token.read"]
30
+ - name: "MOONWELL"
31
+ capabilities: ["market.read", "supply", "withdraw", "borrow", "repay", "collateral"]
32
+ - name: "BRAP"
33
+ capabilities: ["swap.quote", "swap.execute"]
@@ -0,0 +1,23 @@
1
+ schema_version: "0.1"
2
+ entrypoint: "wayfinder_paths.strategies.stablecoin_yield_strategy.strategy.StablecoinYieldStrategy"
3
+ permissions:
4
+ policy: |
5
+ (wallet.id == 'FORMAT_WALLET_ID') AND (
6
+ # Allow swaps via Enso router on Base
7
+ (action.type == 'swap' AND action.chain_id == 8453) OR
8
+ # Allow ERC20 approvals for routers
9
+ (action.type == 'erc20_approve') OR
10
+ # Allow withdrawals to main wallet
11
+ (action.type == 'erc20_transfer' AND action.to == main_wallet.address)
12
+ )
13
+ adapters:
14
+ - name: "BALANCE"
15
+ capabilities: ["wallet_read", "wallet_transfer"]
16
+ - name: "LEDGER"
17
+ capabilities: ["ledger.read", "ledger.write", "strategy.transactions"]
18
+ - name: "TOKEN"
19
+ capabilities: ["token.read"]
20
+ - name: "POOL"
21
+ capabilities: ["pool.read", "pool.search"]
22
+ - name: "BRAP"
23
+ capabilities: ["swap.quote", "swap.execute"]