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.
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +15 -1
- wayfinder_paths/adapters/hyperliquid_adapter/exchange.py +3 -3
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1 -1
- wayfinder_paths/adapters/hyperliquid_adapter/test_cancel_order.py +57 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_exchange_mid_prices.py +52 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_hyperliquid_sdk_live.py +64 -0
- wayfinder_paths/adapters/hyperliquid_adapter/util.py +9 -10
- wayfinder_paths/core/clients/PoolClient.py +1 -1
- wayfinder_paths/mcp/tools/execute.py +48 -16
- wayfinder_paths/mcp/tools/hyperliquid.py +1 -13
- wayfinder_paths/mcp/tools/quotes.py +42 -7
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +24 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +248 -27
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +125 -15
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +29 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/manifest.yaml +33 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +23 -0
- wayfinder_paths/tests/test_manifests.py +93 -0
- wayfinder_paths/tests/test_mcp_balances.py +73 -0
- wayfinder_paths/tests/test_mcp_discovery.py +34 -0
- wayfinder_paths/tests/test_mcp_execute.py +146 -0
- wayfinder_paths/tests/test_mcp_hyperliquid_execute.py +69 -0
- wayfinder_paths/tests/test_mcp_idempotency_store.py +14 -0
- wayfinder_paths/tests/test_mcp_quote_swap.py +60 -0
- wayfinder_paths/tests/test_mcp_run_script.py +47 -0
- wayfinder_paths/tests/test_mcp_tokens.py +49 -0
- wayfinder_paths/tests/test_mcp_utils.py +35 -0
- wayfinder_paths/tests/test_mcp_wallets.py +38 -0
- {wayfinder_paths-0.1.30.dist-info → wayfinder_paths-0.1.31.dist-info}/METADATA +2 -2
- {wayfinder_paths-0.1.30.dist-info → wayfinder_paths-0.1.31.dist-info}/RECORD +32 -15
- {wayfinder_paths-0.1.30.dist-info → wayfinder_paths-0.1.31.dist-info}/WHEEL +1 -1
- {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=
|
|
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(
|
|
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
|
-
|
|
1529
|
-
|
|
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
|
|
1673
|
+
if unused_usd > min_deploy:
|
|
1533
1674
|
self.logger.info(
|
|
1534
|
-
f"Found ${
|
|
1675
|
+
f"Found ${unused_usd:.2f} deployable idle USDC, scaling up position"
|
|
1535
1676
|
)
|
|
1536
|
-
scale_ok, scale_msg = await self._scale_up_position(
|
|
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
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2184
|
-
|
|
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=
|
|
433
|
-
perp_amount=
|
|
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
|
-
#
|
|
441
|
-
#
|
|
442
|
-
#
|
|
443
|
-
#
|
|
444
|
-
#
|
|
445
|
-
#
|
|
446
|
-
#
|
|
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": "
|
|
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": "-
|
|
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
|
|
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": "
|
|
477
|
-
{"coin": "USDC", "total": "
|
|
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"]
|