wayfinder-paths 0.1.11__py3-none-any.whl → 0.1.13__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 (46) hide show
  1. wayfinder_paths/adapters/balance_adapter/adapter.py +3 -7
  2. wayfinder_paths/adapters/brap_adapter/adapter.py +10 -13
  3. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +6 -9
  4. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1 -1
  5. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +44 -5
  6. wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +104 -0
  7. wayfinder_paths/adapters/moonwell_adapter/adapter.py +0 -3
  8. wayfinder_paths/adapters/pool_adapter/README.md +4 -19
  9. wayfinder_paths/adapters/pool_adapter/adapter.py +4 -29
  10. wayfinder_paths/adapters/pool_adapter/examples.json +6 -7
  11. wayfinder_paths/adapters/pool_adapter/test_adapter.py +8 -8
  12. wayfinder_paths/core/clients/AuthClient.py +2 -2
  13. wayfinder_paths/core/clients/BRAPClient.py +2 -2
  14. wayfinder_paths/core/clients/HyperlendClient.py +2 -2
  15. wayfinder_paths/core/clients/PoolClient.py +18 -54
  16. wayfinder_paths/core/clients/TokenClient.py +3 -3
  17. wayfinder_paths/core/clients/WalletClient.py +2 -2
  18. wayfinder_paths/core/clients/WayfinderClient.py +9 -10
  19. wayfinder_paths/core/clients/protocols.py +1 -7
  20. wayfinder_paths/core/config.py +60 -224
  21. wayfinder_paths/core/services/local_evm_txn.py +22 -4
  22. wayfinder_paths/core/strategies/Strategy.py +3 -3
  23. wayfinder_paths/core/strategies/descriptors.py +7 -0
  24. wayfinder_paths/core/utils/evm_helpers.py +5 -1
  25. wayfinder_paths/core/utils/wallets.py +12 -19
  26. wayfinder_paths/core/wallets/README.md +1 -1
  27. wayfinder_paths/run_strategy.py +10 -8
  28. wayfinder_paths/scripts/create_strategy.py +5 -5
  29. wayfinder_paths/scripts/make_wallets.py +5 -5
  30. wayfinder_paths/scripts/run_strategy.py +3 -3
  31. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1 -1
  32. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +196 -515
  33. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +228 -11
  34. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +2 -2
  35. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +1 -0
  36. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +1 -1
  37. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +8 -7
  38. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +2 -2
  39. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +25 -25
  40. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +28 -9
  41. wayfinder_paths/templates/adapter/README.md +1 -1
  42. {wayfinder_paths-0.1.11.dist-info → wayfinder_paths-0.1.13.dist-info}/METADATA +9 -12
  43. {wayfinder_paths-0.1.11.dist-info → wayfinder_paths-0.1.13.dist-info}/RECORD +45 -45
  44. wayfinder_paths/core/settings.py +0 -61
  45. {wayfinder_paths-0.1.11.dist-info → wayfinder_paths-0.1.13.dist-info}/LICENSE +0 -0
  46. {wayfinder_paths-0.1.11.dist-info → wayfinder_paths-0.1.13.dist-info}/WHEEL +0 -0
@@ -12,16 +12,15 @@ import math
12
12
  import random
13
13
  import time
14
14
  from datetime import UTC, datetime, timedelta
15
- from decimal import ROUND_UP, Decimal, getcontext
15
+ from decimal import ROUND_DOWN, ROUND_UP, Decimal, getcontext
16
16
  from pathlib import Path
17
- from statistics import fmean, mean, pstdev
17
+ from statistics import fmean
18
18
  from typing import Any
19
19
 
20
20
  from wayfinder_paths.adapters.balance_adapter.adapter import BalanceAdapter
21
21
  from wayfinder_paths.adapters.hyperliquid_adapter.adapter import (
22
22
  HYPERLIQUID_BRIDGE_ADDRESS,
23
23
  HyperliquidAdapter,
24
- SimpleCache,
25
24
  )
26
25
  from wayfinder_paths.adapters.hyperliquid_adapter.executor import (
27
26
  HyperliquidExecutor,
@@ -44,9 +43,6 @@ from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
44
43
  from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
45
44
  spot_index_from_asset_id as hl_spot_index_from_asset_id,
46
45
  )
47
- from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
48
- sz_decimals_for_asset as hl_sz_decimals_for_asset,
49
- )
50
46
  from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
51
47
  usd_depth_in_band as hl_usd_depth_in_band,
52
48
  )
@@ -91,10 +87,6 @@ from wayfinder_paths.strategies.basis_trading_strategy.types import (
91
87
  # Set decimal precision for precise price/size calculations
92
88
  getcontext().prec = 28
93
89
 
94
- # Hyperliquid price decimal limits
95
- MAX_DECIMALS_PERP = 6
96
- MAX_DECIMALS_SPOT = 8
97
-
98
90
 
99
91
  def _d(x: float | Decimal | str) -> Decimal:
100
92
  """Convert to Decimal for precise calculations."""
@@ -115,21 +107,20 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
115
107
  # Strategy parameters
116
108
  MIN_DEPOSIT_USDC = 25
117
109
  DEFAULT_LOOKBACK_DAYS = 30 # Supports up to ~208 days via chunked API calls
118
- DEFAULT_CONFIDENCE = 0.975
119
110
  DEFAULT_FEE_EPS = 0.003 # 0.3% fee buffer
120
- DEFAULT_OI_FLOOR = 100_000.0 # Min OI in USD (matches Django)
111
+ DEFAULT_OI_FLOOR = 100_000.0 # Min OI in USD
121
112
  DEFAULT_DAY_VLM_FLOOR = 100_000 # Min daily volume
122
113
  DEFAULT_MAX_LEVERAGE = 2
123
114
  GAS_MAXIMUM = 0.01 # ETH
124
115
  DEFAULT_BOOTSTRAP_SIMS = 50
125
116
  DEFAULT_BOOTSTRAP_BLOCK_HOURS = 48
126
117
 
127
- # Liquidation and rebalance thresholds (from Django funding_rate_strategy.py)
118
+ # Liquidation and rebalance thresholds
128
119
  LIQUIDATION_REBALANCE_THRESHOLD = 0.75 # Trigger rebalance at 75% to liquidation
129
120
  LIQUIDATION_STOP_LOSS_THRESHOLD = 0.90 # Stop-loss at 90% to liquidation (closer)
130
121
  FUNDING_REBALANCE_THRESHOLD = 0.02 # Rebalance when funding hits 2% gains
131
122
 
132
- # Position tolerances (from Django hyperliquid_adapter.py)
123
+ # Position tolerances
133
124
  SPOT_POSITION_DUST_TOLERANCE = 0.04 # ±4% size drift allowed
134
125
  MIN_UNUSED_USD = 5.0 # Minimum idle USD threshold
135
126
  UNUSED_REL_EPS = 0.01 # 1% of bankroll idle threshold
@@ -157,6 +148,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
157
148
  "Automated delta-neutral basis trading on Hyperliquid, capturing funding rate payments "
158
149
  "through matched spot long / perp short positions with intelligent leverage sizing."
159
150
  ),
151
+ risk_description="Protocol risk is always present when engaging with DeFi strategies, this includes underlying DeFi protocols and Wayfinder itself. Additional risks include funding rate reversals, liquidity constraints on Hyperliquid, smart contract risk, and temporary capital lock-up during volatile market conditions. During extreme price movements, high volatility can stop out the short side of positions, breaking delta-neutrality and leaving unhedged long exposure that suffers losses when prices revert downward. This can cause significant damage beyond normal funding rate fluctuations.",
152
+ fee_description="Wayfinder takes a 2 basis point (0.02%) builder fee on all orders placed on Hyperliquid through this strategy. If fees remain unpaid, Wayfinder may pause automated management of this vault.",
160
153
  gas_token_symbol="ETH",
161
154
  gas_token_id="ethereum-arbitrum",
162
155
  deposit_token_id="usd-coin-arbitrum",
@@ -231,8 +224,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
231
224
  "builder_fee", self.DEFAULT_BUILDER_FEE
232
225
  )
233
226
 
234
- # Initialize cache
235
- self._cache = SimpleCache()
236
227
  self._margin_table_cache: dict[int, list[dict[str, float]]] = {}
237
228
 
238
229
  # Adapters (some are optional for analysis-only usage).
@@ -315,8 +306,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
315
306
  success, deposit_data = await self.ledger_adapter.get_strategy_net_deposit(
316
307
  wallet_address=self._get_strategy_wallet_address()
317
308
  )
318
- if success and deposit_data:
319
- self.deposit_amount = float(deposit_data.get("net_deposit", 0) or 0)
309
+ if success and deposit_data is not None:
310
+ self.deposit_amount = float(deposit_data)
320
311
  except Exception as e:
321
312
  self.logger.warning(f"Could not fetch deposit data: {e}")
322
313
 
@@ -632,18 +623,15 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
632
623
  Returns:
633
624
  StatusTuple (success, message)
634
625
  """
635
- # If deposit_amount not set, try to detect from Hyperliquid balance
626
+ # If deposit_amount not set, try to detect from Hyperliquid USDC (spot + perp)
636
627
  if self.deposit_amount <= 0:
637
- address = self._get_strategy_wallet_address()
638
- success, user_state = await self.hyperliquid_adapter.get_user_state(address)
639
- if success:
640
- margin_summary = user_state.get("marginSummary", {})
641
- account_value = float(margin_summary.get("accountValue", 0))
642
- if account_value > 1.0:
643
- self.logger.info(
644
- f"Detected ${account_value:.2f} on Hyperliquid, using as deposit amount"
645
- )
646
- self.deposit_amount = account_value
628
+ perp_margin, spot_usdc = await self._get_undeployed_capital()
629
+ total_usdc = perp_margin + spot_usdc
630
+ if total_usdc > 1.0:
631
+ self.logger.info(
632
+ f"Detected ${total_usdc:.2f} USDC on Hyperliquid (spot+perp), using as deposit amount"
633
+ )
634
+ self.deposit_amount = total_usdc
647
635
 
648
636
  if self.deposit_amount <= 0:
649
637
  return (False, "No deposit to manage. Call deposit() first.")
@@ -662,7 +650,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
662
650
  Analyze basis trading opportunities without executing.
663
651
 
664
652
  Uses the Net-APY + stop-churn backtest solver with block-bootstrap
665
- resampling (ported from Django's NetApyBasisTradingService).
653
+ resampling.
666
654
 
667
655
  Args:
668
656
  deposit_usdc: Hypothetical deposit amount for sizing calculations (default $1000)
@@ -1113,8 +1101,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1113
1101
  wallet_address=self._get_strategy_wallet_address()
1114
1102
  )
1115
1103
  net_deposit = (
1116
- float(deposit_data.get("net_deposit", 0) or 0)
1117
- if success
1104
+ float(deposit_data)
1105
+ if success and deposit_data is not None
1118
1106
  else self.deposit_amount
1119
1107
  )
1120
1108
  except Exception:
@@ -1207,6 +1195,13 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1207
1195
  self.logger.info("Analyzing basis trading opportunities...")
1208
1196
 
1209
1197
  try:
1198
+ # Use actual on-exchange USDC (spot + perp) for sizing when opening a fresh position.
1199
+ # This handles liquidation scenarios where most USDC ends up in spot.
1200
+ perp_margin, spot_usdc = await self._get_undeployed_capital()
1201
+ total_usdc = perp_margin + spot_usdc
1202
+ if total_usdc > 1.0:
1203
+ self.deposit_amount = total_usdc
1204
+
1210
1205
  best: dict[str, Any] | None = None
1211
1206
 
1212
1207
  snapshot = self._snapshot_from_config()
@@ -1350,21 +1345,14 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1350
1345
  self.logger.warning(f"Failed to set leverage: {lev_result}")
1351
1346
  # Continue anyway - leverage might already be set
1352
1347
 
1353
- # Step 3: Transfer USDC from perp to spot for spot purchase
1354
- # We need approximately order_usd in spot to buy the asset
1355
- self.logger.info(
1356
- f"Transferring ${order_usd:.2f} from perp to spot for {coin}"
1357
- )
1358
- (
1359
- success,
1360
- transfer_result,
1361
- ) = await self.hyperliquid_adapter.transfer_perp_to_spot(
1362
- amount=order_usd,
1348
+ # Step 3: Ensure USDC is split correctly between spot and perp.
1349
+ # Target: spot has order_usd for the spot buy, perp holds the remainder as margin.
1350
+ split_ok, split_msg = await self._rebalance_usdc_between_perp_and_spot(
1351
+ target_spot_usdc=order_usd,
1363
1352
  address=address,
1364
1353
  )
1365
- if not success:
1366
- self.logger.warning(f"Perp to spot transfer failed: {transfer_result}")
1367
- # May fail if already in spot, continue
1354
+ if not split_ok:
1355
+ self.logger.warning(f"USDC rebalance failed: {split_msg}")
1368
1356
 
1369
1357
  # Step 4: Execute paired fill
1370
1358
  filler = PairedFiller(
@@ -1378,8 +1366,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1378
1366
  perp_filled,
1379
1367
  spot_notional,
1380
1368
  perp_notional,
1381
- spot_pointers,
1382
- perp_pointers,
1369
+ _spot_pointers,
1370
+ _perp_pointers,
1383
1371
  ) = await filler.fill_pair_units(
1384
1372
  coin=coin,
1385
1373
  spot_asset_id=spot_asset_id,
@@ -1488,6 +1476,76 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1488
1476
 
1489
1477
  return withdrawable, spot_usdc
1490
1478
 
1479
+ async def _rebalance_usdc_between_perp_and_spot(
1480
+ self,
1481
+ *,
1482
+ target_spot_usdc: float,
1483
+ address: str,
1484
+ ) -> tuple[bool, str]:
1485
+ """
1486
+ Rebalance Hyperliquid USDC between spot and perp to hit a target spot USDC balance.
1487
+
1488
+ Used before opening/scaling a basis position so we can:
1489
+ - fund the spot buy (spot USDC ~= target_spot_usdc)
1490
+ - keep the remainder in perp as margin (perp USDC ~= total - target_spot_usdc)
1491
+ """
1492
+ if target_spot_usdc <= 0:
1493
+ return False, "Target spot USDC must be positive"
1494
+
1495
+ perp_margin, spot_usdc = await self._get_undeployed_capital()
1496
+ total_usdc = perp_margin + spot_usdc
1497
+ if total_usdc <= 0:
1498
+ return False, "No deployable USDC on Hyperliquid"
1499
+
1500
+ # Operate at cent precision to avoid dust churn.
1501
+ eps = 0.01
1502
+ target = float(
1503
+ Decimal(str(target_spot_usdc)).quantize(Decimal("0.01"), rounding=ROUND_UP)
1504
+ )
1505
+
1506
+ if target > total_usdc + eps:
1507
+ return (
1508
+ False,
1509
+ f"Target spot ${target:.2f} exceeds total deployable ${total_usdc:.2f}",
1510
+ )
1511
+
1512
+ delta = target - spot_usdc
1513
+ if abs(delta) < eps:
1514
+ return True, "Spot/perp USDC already balanced"
1515
+
1516
+ if delta > 0:
1517
+ # Need more spot USDC: move from perp to spot.
1518
+ amount = float(
1519
+ Decimal(str(min(delta, perp_margin))).quantize(
1520
+ Decimal("0.01"), rounding=ROUND_UP
1521
+ )
1522
+ )
1523
+ if amount < eps:
1524
+ return True, "No meaningful perp->spot transfer needed"
1525
+ success, result = await self.hyperliquid_adapter.transfer_perp_to_spot(
1526
+ amount=amount,
1527
+ address=address,
1528
+ )
1529
+ if not success:
1530
+ return False, f"Perp->spot transfer failed: {result}"
1531
+ return True, f"Transferred ${amount:.2f} perp->spot"
1532
+
1533
+ # Need more perp USDC: move from spot to perp.
1534
+ amount = float(
1535
+ Decimal(str(min(-delta, spot_usdc))).quantize(
1536
+ Decimal("0.01"), rounding=ROUND_DOWN
1537
+ )
1538
+ )
1539
+ if amount < eps:
1540
+ return True, "No meaningful spot->perp transfer needed"
1541
+ success, result = await self.hyperliquid_adapter.transfer_spot_to_perp(
1542
+ amount=amount,
1543
+ address=address,
1544
+ )
1545
+ if not success:
1546
+ return False, f"Spot->perp transfer failed: {result}"
1547
+ return True, f"Transferred ${amount:.2f} spot->perp"
1548
+
1491
1549
  async def _scale_up_position(self, additional_capital: float) -> StatusTuple:
1492
1550
  """
1493
1551
  Add capital to existing position without breaking it.
@@ -1553,18 +1611,14 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1553
1611
  f"(${order_usd:.2f}) at {leverage}x leverage"
1554
1612
  )
1555
1613
 
1556
- # Transfer USDC from perp to spot for the spot purchase
1557
- perp_margin, spot_usdc = await self._get_undeployed_capital()
1558
-
1559
- if perp_margin > 1.0 and spot_usdc < order_usd:
1560
- # Need to move some from perp margin to spot
1561
- transfer_amount = min(perp_margin, order_usd - spot_usdc)
1562
- success, result = await self.hyperliquid_adapter.transfer_perp_to_spot(
1563
- amount=transfer_amount,
1564
- address=address,
1565
- )
1566
- if not success:
1567
- self.logger.warning(f"Perp to spot transfer failed: {result}")
1614
+ # Ensure idle USDC is split correctly between spot and perp for this scale-up.
1615
+ # Target: spot has order_usd for the spot buy, perp holds the remainder as margin.
1616
+ split_ok, split_msg = await self._rebalance_usdc_between_perp_and_spot(
1617
+ target_spot_usdc=order_usd,
1618
+ address=address,
1619
+ )
1620
+ if not split_ok:
1621
+ self.logger.warning(f"USDC rebalance failed: {split_msg}")
1568
1622
 
1569
1623
  # Execute paired fill to add to both legs
1570
1624
  filler = PairedFiller(
@@ -1645,6 +1699,22 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1645
1699
  # Calculate deposited amount from current on-exchange value
1646
1700
  total_value, hl_value, _ = await self._get_total_portfolio_value()
1647
1701
 
1702
+ # ------------------------------------------------------------------ #
1703
+ # Emergency: Near-liquidation risk management #
1704
+ # ------------------------------------------------------------------ #
1705
+ near_liq, near_msg = await self._is_near_liquidation(state)
1706
+ if near_liq:
1707
+ self.logger.warning(f"Near liquidation on {coin}: {near_msg}")
1708
+ # Close both legs (sell spot, buy perp) and redeploy into a fresh position.
1709
+ # This bypasses rotation cooldown because it's an emergency safety action.
1710
+ close_success, close_msg = await self._close_position()
1711
+ if not close_success:
1712
+ return (
1713
+ False,
1714
+ f"Emergency rebalance failed - could not close: {close_msg}",
1715
+ )
1716
+ return await self._find_and_open_position()
1717
+
1648
1718
  # ------------------------------------------------------------------ #
1649
1719
  # Check 1: Rebalance needed? #
1650
1720
  # ------------------------------------------------------------------ #
@@ -1728,6 +1798,70 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
1728
1798
  f"Position on {coin} healthy, age: {position_age_hours:.1f}h",
1729
1799
  )
1730
1800
 
1801
+ async def _is_near_liquidation(self, state: dict[str, Any]) -> tuple[bool, str]:
1802
+ """
1803
+ Check whether the perp leg is too close to liquidation.
1804
+
1805
+ For a short perp, liquidation is ABOVE entry. We measure progress from entry -> liquidation:
1806
+ frac = (mid - entry) / (liq - entry)
1807
+ """
1808
+ if self.current_position is None:
1809
+ return False, "No position"
1810
+
1811
+ coin = self.current_position.coin
1812
+
1813
+ perp_pos = None
1814
+ for pos_wrapper in state.get("assetPositions", []):
1815
+ pos = pos_wrapper.get("position", {})
1816
+ if pos.get("coin") == coin:
1817
+ perp_pos = pos
1818
+ break
1819
+
1820
+ if not perp_pos:
1821
+ return False, "No perp position found"
1822
+
1823
+ try:
1824
+ szi = float(perp_pos.get("szi", 0) or 0)
1825
+ except (TypeError, ValueError):
1826
+ szi = 0.0
1827
+
1828
+ # Only applies to short perps (basis trade is short perp).
1829
+ if szi >= 0:
1830
+ return False, "Perp is not short"
1831
+
1832
+ entry_px_raw = perp_pos.get("entryPx") or self.current_position.entry_price
1833
+ liq_px_raw = perp_pos.get("liquidationPx")
1834
+ try:
1835
+ entry_px = float(entry_px_raw or 0)
1836
+ liq_px = float(liq_px_raw or 0)
1837
+ except (TypeError, ValueError):
1838
+ return False, "Missing entry/liquidation price"
1839
+
1840
+ if entry_px <= 0 or liq_px <= 0 or liq_px <= entry_px:
1841
+ return False, "Invalid entry/liquidation prices"
1842
+
1843
+ success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
1844
+ if not success:
1845
+ return False, "Failed to fetch mid prices"
1846
+
1847
+ mid_px = float(mids.get(coin, 0.0) or 0.0)
1848
+ if mid_px <= 0:
1849
+ return False, "Missing mid price"
1850
+
1851
+ denom = liq_px - entry_px
1852
+ frac = (mid_px - entry_px) / denom if denom != 0 else 0.0
1853
+
1854
+ if frac >= self.LIQUIDATION_REBALANCE_THRESHOLD:
1855
+ return (
1856
+ True,
1857
+ f"mid=${mid_px:.4f} is {frac:.2%} of the way from entry=${entry_px:.4f} to liq=${liq_px:.4f}",
1858
+ )
1859
+
1860
+ return (
1861
+ False,
1862
+ f"mid=${mid_px:.4f} is {frac:.2%} of the way from entry=${entry_px:.4f} to liq=${liq_px:.4f}",
1863
+ )
1864
+
1731
1865
  async def _verify_leg_balance(self, state: dict[str, Any]) -> tuple[bool, str]:
1732
1866
  """
1733
1867
  Verify that spot and perp legs are balanced (delta neutral).
@@ -2040,7 +2174,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
2040
2174
  """
2041
2175
  Check if current delta-neutral position needs rebalancing.
2042
2176
 
2043
- Implements 7 health checks from Django hyperliquid_adapter.py:
2177
+ Implements the following health checks:
2044
2178
  1. Missing positions
2045
2179
  2. Asset mismatch (if best specified)
2046
2180
  3. Funding accumulation threshold
@@ -2146,74 +2280,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
2146
2280
 
2147
2281
  return 0.0
2148
2282
 
2149
- def _calculate_unused_usd(
2150
- self, state: dict[str, Any], deposited_amount: float
2151
- ) -> float:
2152
- """Calculate unused USD not deployed in positions."""
2153
- # Get account value
2154
- margin_summary = state.get("marginSummary", {})
2155
- account_value = float(margin_summary.get("accountValue", 0))
2156
-
2157
- # Get total position value
2158
- total_ntl = float(margin_summary.get("totalNtlPos", 0))
2159
-
2160
- # Unused = account value - position value
2161
- # For basis trading, we want most capital deployed
2162
- unused = account_value - abs(total_ntl)
2163
- return max(0.0, unused)
2164
-
2165
- async def _validate_stop_loss_orders(
2166
- self,
2167
- state: dict[str, Any],
2168
- perp_position: dict[str, Any],
2169
- ) -> tuple[bool, str]:
2170
- """Validate stop-loss orders exist and are below liquidation price."""
2171
- address = self._get_strategy_wallet_address()
2172
-
2173
- # Get liquidation price from perp position
2174
- liquidation_price = perp_position.get("liquidationPx")
2175
- if liquidation_price is None:
2176
- return False, "No liquidation price found"
2177
- liquidation_price = float(liquidation_price)
2178
-
2179
- # Get open orders
2180
- success, open_orders = await self.hyperliquid_adapter.get_open_orders(address)
2181
- if not success:
2182
- return False, "Failed to fetch open orders"
2183
-
2184
- perp_asset_id = perp_position.get("asset_id")
2185
-
2186
- # Find stop-loss orders for perp
2187
- perp_sl_order = None
2188
-
2189
- for order in open_orders:
2190
- order_type = order.get("orderType", "")
2191
- if "trigger" not in str(order_type).lower():
2192
- continue
2193
-
2194
- asset = order.get("coin") or order.get("asset")
2195
- coin_match = (
2196
- asset == self.current_position.coin if self.current_position else False
2197
- )
2198
-
2199
- if coin_match or order.get("asset_id") == perp_asset_id:
2200
- perp_sl_order = order
2201
- break
2202
-
2203
- # Validate perp stop-loss exists
2204
- if perp_sl_order is None:
2205
- return False, "Missing perp stop-loss order"
2206
-
2207
- # Validate price is below liquidation (for short, SL triggers on price RISE)
2208
- perp_sl_price = float(perp_sl_order.get("triggerPx", 0))
2209
- if perp_sl_price >= liquidation_price:
2210
- return (
2211
- False,
2212
- f"Perp stop-loss {perp_sl_price} >= liquidation {liquidation_price}",
2213
- )
2214
-
2215
- return True, "Stop-loss orders valid"
2216
-
2217
2283
  async def _place_stop_loss_orders(
2218
2284
  self,
2219
2285
  coin: str,
@@ -2515,132 +2581,9 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
2515
2581
  return total_value, hl_value, strategy_wallet_value
2516
2582
 
2517
2583
  # ------------------------------------------------------------------ #
2518
- # Analysis Methods (ported from BasisTradingService) #
2584
+ # Analysis Methods #
2519
2585
  # ------------------------------------------------------------------ #
2520
2586
 
2521
- async def find_best_basis_trades(
2522
- self,
2523
- deposit_usdc: float,
2524
- lookback_days: int = 180,
2525
- confidence: float = 0.975,
2526
- fee_eps: float = 0.003,
2527
- oi_floor: float = 50.0,
2528
- day_vlm_floor: float = 1e5,
2529
- horizons_days: list[int] | None = None,
2530
- max_leverage: int = 3,
2531
- ) -> list[dict[str, Any]]:
2532
- """
2533
- Find optimal basis trading opportunities.
2534
-
2535
- Args:
2536
- deposit_usdc: Total deposit amount in USDC
2537
- lookback_days: Days of historical data to analyze
2538
- confidence: VaR confidence level (default 97.5%)
2539
- fee_eps: Fee buffer as fraction of notional
2540
- oi_floor: Minimum open interest threshold in USD
2541
- day_vlm_floor: Minimum daily volume threshold in USD
2542
- horizons_days: Time horizons for risk calculation
2543
- max_leverage: Maximum leverage allowed
2544
-
2545
- Returns:
2546
- List of basis trade opportunities sorted by expected APY
2547
- """
2548
- if horizons_days is None:
2549
- horizons_days = [1, 7]
2550
-
2551
- # Validate lookback doesn't exceed HL's 5000 candle limit
2552
- max_hours = 5000
2553
- max_days = max_hours // 24
2554
- if lookback_days > max_days:
2555
- self.logger.warning(
2556
- f"Lookback {lookback_days}d exceeds limit. Capping at {max_days}d"
2557
- )
2558
- lookback_days = max_days
2559
-
2560
- try:
2561
- # Get perpetual market data
2562
- (
2563
- success,
2564
- perps_ctx_pack,
2565
- ) = await self.hyperliquid_adapter.get_meta_and_asset_ctxs()
2566
- if not success:
2567
- raise ValueError(f"Failed to fetch perp metadata: {perps_ctx_pack}")
2568
-
2569
- perps_meta_list = perps_ctx_pack[0]["universe"]
2570
- perps_ctxs = perps_ctx_pack[1]
2571
-
2572
- coin_to_ctx: dict[str, Any] = {}
2573
- coin_to_maxlev: dict[str, int] = {}
2574
- coin_to_margin_table: dict[str, int | None] = {}
2575
- coins: list[str] = []
2576
-
2577
- for meta, ctx in zip(perps_meta_list, perps_ctxs, strict=False):
2578
- coin = meta["name"]
2579
- coin_to_ctx[coin] = ctx
2580
- coin_to_maxlev[coin] = int(meta.get("maxLeverage", 10))
2581
- coin_to_margin_table[coin] = meta.get("marginTableId")
2582
- coins.append(coin)
2583
-
2584
- perps_set = set(coins)
2585
-
2586
- # Get spot market data
2587
- success, spot_meta = await self.hyperliquid_adapter.get_spot_meta()
2588
- if not success:
2589
- raise ValueError(f"Failed to fetch spot metadata: {spot_meta}")
2590
-
2591
- tokens = spot_meta.get("tokens", [])
2592
- spot_pairs = spot_meta.get("universe", [])
2593
- idx_to_token = {t["index"]: t["name"] for t in tokens}
2594
-
2595
- # Find candidate basis pairs
2596
- candidates = self._find_basis_candidates(
2597
- spot_pairs, idx_to_token, perps_set
2598
- )
2599
- self.logger.info(f"Found {len(candidates)} spot-perp candidate pairs")
2600
-
2601
- # Get perp asset ID mapping
2602
- perp_coin_to_asset_id = {
2603
- k: v
2604
- for k, v in self.hyperliquid_adapter.coin_to_asset.items()
2605
- if v < 10000
2606
- }
2607
-
2608
- # Filter by liquidity
2609
- liquid = await self._filter_by_liquidity(
2610
- candidates=candidates,
2611
- coin_to_ctx=coin_to_ctx,
2612
- coin_to_maxlev=coin_to_maxlev,
2613
- coin_to_margin_table=coin_to_margin_table,
2614
- deposit_usdc=deposit_usdc,
2615
- max_leverage=max_leverage,
2616
- oi_floor=oi_floor,
2617
- day_vlm_floor=day_vlm_floor,
2618
- perp_coin_to_asset_id=perp_coin_to_asset_id,
2619
- )
2620
- self.logger.info(
2621
- f"After liquidity filter: {len(liquid)} candidates "
2622
- f"(OI >= ${oi_floor}, volume >= ${day_vlm_floor:,.0f})"
2623
- )
2624
-
2625
- # Analyze each candidate
2626
- results = await self._analyze_candidates(
2627
- liquid,
2628
- deposit_usdc,
2629
- lookback_days,
2630
- confidence,
2631
- fee_eps,
2632
- horizons_days,
2633
- )
2634
- self.logger.info(f"After historical analysis: {len(results)} opportunities")
2635
-
2636
- # Sort by expected APY
2637
- results.sort(key=self._get_safe_apy_key, reverse=True)
2638
- return results
2639
-
2640
- except Exception as e:
2641
- self.logger.error(f"Error finding basis trades: {e}")
2642
- raise
2643
-
2644
2587
  def _find_basis_candidates(
2645
2588
  self,
2646
2589
  spot_pairs: list[dict],
@@ -2775,249 +2718,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
2775
2718
 
2776
2719
  return liquid
2777
2720
 
2778
- async def _analyze_candidates(
2779
- self,
2780
- candidates: list[BasisCandidate],
2781
- deposit_usdc: float,
2782
- lookback_days: int,
2783
- confidence: float,
2784
- fee_eps: float,
2785
- horizons_days: list[int],
2786
- ) -> list[dict[str, Any]]:
2787
- """Analyze each liquid candidate for basis trading metrics."""
2788
- ms_now = int(time.time() * 1000)
2789
- start_ms = ms_now - int(lookback_days * 24 * 3600 * 1000)
2790
- z = self._z_from_conf(confidence)
2791
-
2792
- results: list[dict[str, Any]] = []
2793
-
2794
- required_hours = lookback_days * 24
2795
- skipped_reasons: dict[str, list[str]] = {
2796
- "no_funding": [],
2797
- "no_candles": [],
2798
- "insufficient_funding": [],
2799
- "insufficient_candles": [],
2800
- }
2801
-
2802
- for candidate in candidates:
2803
- coin = candidate.coin
2804
- spot_sym = candidate.spot_pair
2805
-
2806
- # Fetch funding history with chunking for longer lookbacks
2807
- success, funding_data = await self._fetch_funding_history_chunked(
2808
- coin, start_ms, ms_now
2809
- )
2810
- if not success or not funding_data:
2811
- skipped_reasons["no_funding"].append(coin)
2812
- continue
2813
-
2814
- hourly_funding = [float(x.get("fundingRate", 0.0)) for x in funding_data]
2815
-
2816
- # Fetch candle data with chunking for longer lookbacks
2817
- success, candle_data = await self._fetch_candles_chunked(
2818
- coin, "1h", start_ms, ms_now
2819
- )
2820
- if not success or not candle_data:
2821
- skipped_reasons["no_candles"].append(coin)
2822
- continue
2823
-
2824
- closes = [float(c.get("c", 0)) for c in candle_data if c.get("c")]
2825
- highs = [float(c.get("h", 0)) for c in candle_data if c.get("h")]
2826
-
2827
- # Require at least 7 days of data minimum, or 50% of lookback for longer periods
2828
- min_required = max(7 * 24, required_hours // 2)
2829
- if len(hourly_funding) < min_required:
2830
- skipped_reasons["insufficient_funding"].append(
2831
- f"{coin}({len(hourly_funding)}/{min_required})"
2832
- )
2833
- continue
2834
- if len(closes) < min_required or len(highs) < min_required:
2835
- skipped_reasons["insufficient_candles"].append(
2836
- f"{coin}(closes={len(closes)},highs={len(highs)}/{min_required})"
2837
- )
2838
- continue
2839
-
2840
- # Calculate price volatility
2841
- sigma_hourly = (
2842
- pstdev(
2843
- [(closes[i] / closes[i - 1] - 1.0) for i in range(1, len(closes))]
2844
- )
2845
- if len(closes) > 1
2846
- else 0.005
2847
- )
2848
-
2849
- # Calculate funding statistics
2850
- funding_stats = self._calculate_funding_stats(hourly_funding)
2851
-
2852
- # Calculate safe leverages
2853
- max_lev = candidate.target_leverage
2854
- m_maint = self.maintenance_rate_from_max_leverage(max_lev)
2855
-
2856
- safe = self._calculate_safe_leverages(
2857
- hourly_funding=hourly_funding,
2858
- closes=closes,
2859
- highs=highs,
2860
- z=z,
2861
- m_maint=m_maint,
2862
- fee_eps=fee_eps,
2863
- max_lev=max_lev,
2864
- deposit_usdc=deposit_usdc,
2865
- horizons_days=horizons_days,
2866
- )
2867
-
2868
- result = {
2869
- "coin": coin,
2870
- "spot_pair": spot_sym,
2871
- "spot_asset_id": candidate.spot_asset_id,
2872
- "perp_asset_id": candidate.perp_asset_id,
2873
- "maxLeverage": max_lev,
2874
- "maintenance_rate_est": m_maint,
2875
- "openInterest": candidate.open_interest_base,
2876
- "day_notional_volume": candidate.day_notional_usd,
2877
- "funding_stats": funding_stats,
2878
- "price_stats": {
2879
- "sigma_hourly": sigma_hourly,
2880
- "z_for_confidence": z,
2881
- "confidence": confidence,
2882
- },
2883
- "safe": safe,
2884
- "depth_checks": candidate.depth_checks,
2885
- }
2886
-
2887
- results.append(result)
2888
-
2889
- # Log skip reasons
2890
- for reason, coins in skipped_reasons.items():
2891
- if coins:
2892
- self.logger.debug(
2893
- f"Skipped ({reason}): {', '.join(coins[:5])}{'...' if len(coins) > 5 else ''}"
2894
- )
2895
-
2896
- return results
2897
-
2898
- def _calculate_funding_stats(self, hourly_funding: list[float]) -> dict[str, Any]:
2899
- """Calculate comprehensive funding rate statistics."""
2900
- if not hourly_funding:
2901
- return {
2902
- "mean_hourly": 0.0,
2903
- "neg_hour_fraction": 0.0,
2904
- "hourly_vol": 0.0,
2905
- "worst_24h_sum": 0.0,
2906
- "worst_7d_sum": 0.0,
2907
- "points": 0,
2908
- }
2909
-
2910
- mean_hourly = mean(hourly_funding)
2911
- neg_hour_frac = sum(1 for r in hourly_funding if r < 0.0) / len(hourly_funding)
2912
- hourly_vol = pstdev(hourly_funding) if len(hourly_funding) > 1 else 0.0
2913
- worst_24h = self._rolling_min_sum(hourly_funding, 24)
2914
- worst_7d = self._rolling_min_sum(hourly_funding, 24 * 7)
2915
-
2916
- return {
2917
- "mean_hourly": mean_hourly,
2918
- "neg_hour_fraction": neg_hour_frac,
2919
- "hourly_vol": hourly_vol,
2920
- "worst_24h_sum": worst_24h,
2921
- "worst_7d_sum": worst_7d,
2922
- "points": len(hourly_funding),
2923
- }
2924
-
2925
- def _calculate_safe_leverages(
2926
- self,
2927
- hourly_funding: list[float],
2928
- closes: list[float],
2929
- highs: list[float],
2930
- z: float,
2931
- m_maint: float,
2932
- fee_eps: float,
2933
- max_lev: int,
2934
- deposit_usdc: float,
2935
- horizons_days: list[int],
2936
- ) -> dict[str, Any]:
2937
- """Calculate safe leverage for each time horizon."""
2938
- results: dict[str, Any] = {}
2939
-
2940
- for horizon in horizons_days:
2941
- window = horizon * 24
2942
- b_star = self._worst_buffer_requirement(
2943
- closes, highs, hourly_funding, window, m_maint, fee_eps
2944
- )
2945
-
2946
- if b_star >= 1.0:
2947
- results[f"{horizon}d"] = {
2948
- "pass": False,
2949
- "leverage": 0,
2950
- "reason": "Buffer requirement exceeds 100%",
2951
- }
2952
- continue
2953
-
2954
- safe_lev = min(max_lev, int(1.0 / b_star)) if b_star > 0 else max_lev
2955
-
2956
- # Calculate expected APY
2957
- mean_funding = mean(hourly_funding) if hourly_funding else 0
2958
- expected_apy = mean_funding * 24 * 365 * safe_lev
2959
-
2960
- # Estimate quantities
2961
- order_usd = deposit_usdc * (safe_lev / (safe_lev + 1))
2962
- avg_price = mean(closes) if closes else 1.0
2963
- qty = order_usd / avg_price if avg_price > 0 else 0
2964
-
2965
- results[f"{horizon}d"] = {
2966
- "pass": True,
2967
- "leverage": safe_lev,
2968
- "buffer_requirement": b_star,
2969
- "expected_apy_pct": expected_apy,
2970
- "spot_qty": qty,
2971
- "perp_qty": qty,
2972
- "order_usd": order_usd,
2973
- }
2974
-
2975
- return results
2976
-
2977
- def _worst_buffer_requirement(
2978
- self,
2979
- closes: list[float],
2980
- highs: list[float],
2981
- hourly_funding: list[float],
2982
- window: int,
2983
- mmr: float,
2984
- fee_eps: float,
2985
- ) -> float:
2986
- """
2987
- Calculate worst-case buffer requirement over rolling windows.
2988
-
2989
- Uses deterministic historical "stress test" approach.
2990
- """
2991
- n = min(len(closes), len(highs), len(hourly_funding))
2992
- if n < window or n == 0:
2993
- return 1.0
2994
-
2995
- worst_req = 0.0
2996
-
2997
- for start in range(n - window + 1):
2998
- end = start + window
2999
- entry_price = closes[start]
3000
- if entry_price <= 0:
3001
- continue
3002
-
3003
- cum_f = 0.0
3004
- runup = 0.0
3005
-
3006
- for i in range(start, end):
3007
- peak = highs[i]
3008
- step_runup = (peak / entry_price - 1.0) if entry_price > 0 else 0.0
3009
- runup = max(runup, step_runup)
3010
-
3011
- r = hourly_funding[i] if i < len(hourly_funding) else 0.0
3012
- if r < 0.0:
3013
- cum_f += (-r) * (1.0 + runup)
3014
-
3015
- req = mmr * (1.0 + runup) + runup + cum_f + fee_eps
3016
- if req > worst_req:
3017
- worst_req = req
3018
-
3019
- return worst_req
3020
-
3021
2721
  # ------------------------------------------------------------------ #
3022
2722
  # Chunked Data Fetching #
3023
2723
  # ------------------------------------------------------------------ #
@@ -3177,7 +2877,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
3177
2877
  return True, all_candles
3178
2878
 
3179
2879
  # ------------------------------------------------------------------ #
3180
- # Net APY Solver + Bootstrap (ported from Django NetApyBasisTradingService)
2880
+ # Net APY Solver + Bootstrap #
3181
2881
  # ------------------------------------------------------------------ #
3182
2882
 
3183
2883
  def _spot_index_from_asset_id(self, spot_asset_id: int) -> int:
@@ -3904,16 +3604,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
3904
3604
 
3905
3605
  return worst_req if worst_req > 0 else float(fallback_mmr + fee_eps)
3906
3606
 
3907
- def get_sz_decimals_for_hypecore_asset(self, asset_id: int) -> int:
3908
- try:
3909
- mapping = self.hyperliquid_adapter.asset_to_sz_decimals
3910
- except Exception as exc: # noqa: BLE001
3911
- raise ValueError("Hyperliquid asset_to_sz_decimals not available") from exc
3912
-
3913
- if not isinstance(mapping, dict):
3914
- raise ValueError(f"Unknown asset_id {asset_id}: missing szDecimals")
3915
- return hl_sz_decimals_for_asset(mapping, asset_id)
3916
-
3917
3607
  def _size_step(self, asset_id: int) -> Decimal:
3918
3608
  try:
3919
3609
  mapping = self.hyperliquid_adapter.asset_to_sz_decimals
@@ -4441,15 +4131,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
4441
4131
  return 0.5
4442
4132
  return 0.5 / max_lev
4443
4133
 
4444
- @staticmethod
4445
- def _get_safe_apy_key(result: dict[str, Any]) -> float:
4446
- """Sort key for results by 7d expected APY."""
4447
- safe = result.get("safe", {})
4448
- safe_7d = safe.get("7d", {})
4449
- if not safe_7d.get("pass", False):
4450
- return -999.0
4451
- return safe_7d.get("expected_apy_pct", 0.0)
4452
-
4453
4134
  def _get_strategy_wallet_address(self) -> str:
4454
4135
  """Get strategy wallet address from config."""
4455
4136
  strategy_wallet = self.config.get("strategy_wallet")