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.
- wayfinder_paths/adapters/balance_adapter/adapter.py +3 -7
- wayfinder_paths/adapters/brap_adapter/adapter.py +10 -13
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +6 -9
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1 -1
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +44 -5
- wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +104 -0
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +0 -3
- wayfinder_paths/adapters/pool_adapter/README.md +4 -19
- wayfinder_paths/adapters/pool_adapter/adapter.py +4 -29
- wayfinder_paths/adapters/pool_adapter/examples.json +6 -7
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +8 -8
- wayfinder_paths/core/clients/AuthClient.py +2 -2
- wayfinder_paths/core/clients/BRAPClient.py +2 -2
- wayfinder_paths/core/clients/HyperlendClient.py +2 -2
- wayfinder_paths/core/clients/PoolClient.py +18 -54
- wayfinder_paths/core/clients/TokenClient.py +3 -3
- wayfinder_paths/core/clients/WalletClient.py +2 -2
- wayfinder_paths/core/clients/WayfinderClient.py +9 -10
- wayfinder_paths/core/clients/protocols.py +1 -7
- wayfinder_paths/core/config.py +60 -224
- wayfinder_paths/core/services/local_evm_txn.py +22 -4
- wayfinder_paths/core/strategies/Strategy.py +3 -3
- wayfinder_paths/core/strategies/descriptors.py +7 -0
- wayfinder_paths/core/utils/evm_helpers.py +5 -1
- wayfinder_paths/core/utils/wallets.py +12 -19
- wayfinder_paths/core/wallets/README.md +1 -1
- wayfinder_paths/run_strategy.py +10 -8
- wayfinder_paths/scripts/create_strategy.py +5 -5
- wayfinder_paths/scripts/make_wallets.py +5 -5
- wayfinder_paths/scripts/run_strategy.py +3 -3
- wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1 -1
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +196 -515
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +228 -11
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +2 -2
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +1 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +1 -1
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +8 -7
- wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +2 -2
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +25 -25
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +28 -9
- wayfinder_paths/templates/adapter/README.md +1 -1
- {wayfinder_paths-0.1.11.dist-info → wayfinder_paths-0.1.13.dist-info}/METADATA +9 -12
- {wayfinder_paths-0.1.11.dist-info → wayfinder_paths-0.1.13.dist-info}/RECORD +45 -45
- wayfinder_paths/core/settings.py +0 -61
- {wayfinder_paths-0.1.11.dist-info → wayfinder_paths-0.1.13.dist-info}/LICENSE +0 -0
- {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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
626
|
+
# If deposit_amount not set, try to detect from Hyperliquid USDC (spot + perp)
|
|
636
627
|
if self.deposit_amount <= 0:
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
if
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
|
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
|
|
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:
|
|
1354
|
-
#
|
|
1355
|
-
self.
|
|
1356
|
-
|
|
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
|
|
1366
|
-
self.logger.warning(f"
|
|
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
|
-
|
|
1382
|
-
|
|
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
|
-
#
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
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
|
|
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
|
|
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
|
|
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")
|