wayfinder-paths 0.1.11__py3-none-any.whl → 0.1.14__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/README.md +13 -14
- wayfinder_paths/adapters/balance_adapter/adapter.py +36 -39
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +123 -0
- wayfinder_paths/adapters/brap_adapter/README.md +11 -16
- wayfinder_paths/adapters/brap_adapter/adapter.py +87 -75
- wayfinder_paths/adapters/brap_adapter/examples.json +63 -52
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +121 -59
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +22 -23
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +114 -60
- 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 +11 -27
- wayfinder_paths/adapters/pool_adapter/adapter.py +11 -37
- wayfinder_paths/adapters/pool_adapter/examples.json +6 -7
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +8 -8
- wayfinder_paths/adapters/token_adapter/README.md +2 -14
- wayfinder_paths/adapters/token_adapter/adapter.py +16 -10
- wayfinder_paths/adapters/token_adapter/examples.json +4 -8
- wayfinder_paths/adapters/token_adapter/test_adapter.py +5 -3
- wayfinder_paths/core/clients/BRAPClient.py +103 -62
- wayfinder_paths/core/clients/ClientManager.py +1 -68
- wayfinder_paths/core/clients/HyperlendClient.py +127 -66
- wayfinder_paths/core/clients/LedgerClient.py +1 -4
- wayfinder_paths/core/clients/PoolClient.py +126 -88
- wayfinder_paths/core/clients/TokenClient.py +92 -37
- wayfinder_paths/core/clients/WalletClient.py +28 -58
- wayfinder_paths/core/clients/WayfinderClient.py +33 -166
- wayfinder_paths/core/clients/__init__.py +0 -2
- wayfinder_paths/core/clients/protocols.py +35 -52
- wayfinder_paths/core/clients/sdk_example.py +37 -22
- wayfinder_paths/core/config.py +60 -224
- wayfinder_paths/core/engine/StrategyJob.py +7 -55
- wayfinder_paths/core/services/local_evm_txn.py +28 -10
- wayfinder_paths/core/services/local_token_txn.py +1 -1
- wayfinder_paths/core/strategies/Strategy.py +3 -5
- wayfinder_paths/core/strategies/descriptors.py +7 -0
- wayfinder_paths/core/utils/evm_helpers.py +7 -3
- wayfinder_paths/core/utils/wallets.py +12 -19
- wayfinder_paths/core/wallets/README.md +1 -1
- wayfinder_paths/run_strategy.py +8 -17
- 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 +206 -526
- 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 +41 -25
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +54 -9
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +1 -1
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +10 -9
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +12 -6
- wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +3 -3
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +110 -78
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +44 -21
- wayfinder_paths/templates/adapter/README.md +1 -1
- wayfinder_paths/templates/strategy/README.md +3 -3
- wayfinder_paths/templates/strategy/test_strategy.py +3 -2
- {wayfinder_paths-0.1.11.dist-info → wayfinder_paths-0.1.14.dist-info}/METADATA +21 -59
- {wayfinder_paths-0.1.11.dist-info → wayfinder_paths-0.1.14.dist-info}/RECORD +64 -65
- wayfinder_paths/core/clients/AuthClient.py +0 -83
- wayfinder_paths/core/settings.py +0 -61
- {wayfinder_paths-0.1.11.dist-info → wayfinder_paths-0.1.14.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.11.dist-info → wayfinder_paths-0.1.14.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",
|
|
@@ -210,9 +203,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
210
203
|
strategy_wallet: dict[str, Any] | None = None,
|
|
211
204
|
web3_service: Web3Service | None = None,
|
|
212
205
|
hyperliquid_executor: HyperliquidExecutor | None = None,
|
|
213
|
-
api_key: str | None = None,
|
|
214
206
|
) -> None:
|
|
215
|
-
super().__init__(
|
|
207
|
+
super().__init__()
|
|
216
208
|
|
|
217
209
|
merged_config = dict(config or {})
|
|
218
210
|
if main_wallet:
|
|
@@ -231,8 +223,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
231
223
|
"builder_fee", self.DEFAULT_BUILDER_FEE
|
|
232
224
|
)
|
|
233
225
|
|
|
234
|
-
# Initialize cache
|
|
235
|
-
self._cache = SimpleCache()
|
|
236
226
|
self._margin_table_cache: dict[int, list[dict[str, float]]] = {}
|
|
237
227
|
|
|
238
228
|
# Adapters (some are optional for analysis-only usage).
|
|
@@ -315,8 +305,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
315
305
|
success, deposit_data = await self.ledger_adapter.get_strategy_net_deposit(
|
|
316
306
|
wallet_address=self._get_strategy_wallet_address()
|
|
317
307
|
)
|
|
318
|
-
if success and deposit_data:
|
|
319
|
-
self.deposit_amount = float(deposit_data
|
|
308
|
+
if success and deposit_data is not None:
|
|
309
|
+
self.deposit_amount = float(deposit_data)
|
|
320
310
|
except Exception as e:
|
|
321
311
|
self.logger.warning(f"Could not fetch deposit data: {e}")
|
|
322
312
|
|
|
@@ -488,7 +478,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
488
478
|
gas_ok,
|
|
489
479
|
gas_res,
|
|
490
480
|
) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
|
|
491
|
-
|
|
481
|
+
query="ethereum-arbitrum", # Native ETH on Arbitrum
|
|
492
482
|
amount=gas_token_amount,
|
|
493
483
|
strategy_name=self.name or "basis_trading_strategy",
|
|
494
484
|
skip_ledger=True,
|
|
@@ -509,7 +499,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
509
499
|
strategy_balance_ok,
|
|
510
500
|
strategy_balance,
|
|
511
501
|
) = await self.balance_adapter.get_balance(
|
|
512
|
-
|
|
502
|
+
query=USDC_ARBITRUM_TOKEN_ID,
|
|
513
503
|
wallet_address=strategy_address,
|
|
514
504
|
)
|
|
515
505
|
strategy_usdc = 0.0
|
|
@@ -527,7 +517,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
527
517
|
move_ok,
|
|
528
518
|
move_res,
|
|
529
519
|
) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
|
|
530
|
-
|
|
520
|
+
query=USDC_ARBITRUM_TOKEN_ID,
|
|
531
521
|
amount=need_to_move,
|
|
532
522
|
strategy_name=self.name or "basis_trading_strategy",
|
|
533
523
|
skip_ledger=True,
|
|
@@ -552,7 +542,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
552
542
|
|
|
553
543
|
# Send USDC to bridge address (deposit credits the sender address on Hyperliquid)
|
|
554
544
|
success, result = await self.balance_adapter.send_to_address(
|
|
555
|
-
|
|
545
|
+
query=USDC_ARBITRUM_TOKEN_ID,
|
|
556
546
|
amount=main_token_amount,
|
|
557
547
|
from_wallet=strategy_wallet,
|
|
558
548
|
to_address=HYPERLIQUID_BRIDGE_ADDRESS,
|
|
@@ -632,18 +622,15 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
632
622
|
Returns:
|
|
633
623
|
StatusTuple (success, message)
|
|
634
624
|
"""
|
|
635
|
-
# If deposit_amount not set, try to detect from Hyperliquid
|
|
625
|
+
# If deposit_amount not set, try to detect from Hyperliquid USDC (spot + perp)
|
|
636
626
|
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
|
|
627
|
+
perp_margin, spot_usdc = await self._get_undeployed_capital()
|
|
628
|
+
total_usdc = perp_margin + spot_usdc
|
|
629
|
+
if total_usdc > 1.0:
|
|
630
|
+
self.logger.info(
|
|
631
|
+
f"Detected ${total_usdc:.2f} USDC on Hyperliquid (spot+perp), using as deposit amount"
|
|
632
|
+
)
|
|
633
|
+
self.deposit_amount = total_usdc
|
|
647
634
|
|
|
648
635
|
if self.deposit_amount <= 0:
|
|
649
636
|
return (False, "No deposit to manage. Call deposit() first.")
|
|
@@ -662,7 +649,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
662
649
|
Analyze basis trading opportunities without executing.
|
|
663
650
|
|
|
664
651
|
Uses the Net-APY + stop-churn backtest solver with block-bootstrap
|
|
665
|
-
resampling
|
|
652
|
+
resampling.
|
|
666
653
|
|
|
667
654
|
Args:
|
|
668
655
|
deposit_usdc: Hypothetical deposit amount for sizing calculations (default $1000)
|
|
@@ -833,7 +820,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
833
820
|
strategy_usdc = 0.0
|
|
834
821
|
try:
|
|
835
822
|
success, balance_data = await self.balance_adapter.get_balance(
|
|
836
|
-
|
|
823
|
+
query=usdc_token_id,
|
|
837
824
|
wallet_address=address,
|
|
838
825
|
)
|
|
839
826
|
if success:
|
|
@@ -878,7 +865,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
878
865
|
send_success,
|
|
879
866
|
send_result,
|
|
880
867
|
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
881
|
-
|
|
868
|
+
query=usdc_token_id,
|
|
882
869
|
amount=amount_to_send,
|
|
883
870
|
strategy_name=self.name,
|
|
884
871
|
skip_ledger=False,
|
|
@@ -1023,7 +1010,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1023
1010
|
final_balance = 0.0
|
|
1024
1011
|
try:
|
|
1025
1012
|
success, balance_data = await self.balance_adapter.get_balance(
|
|
1026
|
-
|
|
1013
|
+
query=usdc_token_id,
|
|
1027
1014
|
wallet_address=address,
|
|
1028
1015
|
)
|
|
1029
1016
|
if success:
|
|
@@ -1044,7 +1031,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1044
1031
|
send_success,
|
|
1045
1032
|
send_result,
|
|
1046
1033
|
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
1047
|
-
|
|
1034
|
+
query=usdc_token_id,
|
|
1048
1035
|
amount=amount_to_send,
|
|
1049
1036
|
strategy_name=self.name,
|
|
1050
1037
|
skip_ledger=False, # Record in ledger
|
|
@@ -1113,8 +1100,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1113
1100
|
wallet_address=self._get_strategy_wallet_address()
|
|
1114
1101
|
)
|
|
1115
1102
|
net_deposit = (
|
|
1116
|
-
float(deposit_data
|
|
1117
|
-
if success
|
|
1103
|
+
float(deposit_data)
|
|
1104
|
+
if success and deposit_data is not None
|
|
1118
1105
|
else self.deposit_amount
|
|
1119
1106
|
)
|
|
1120
1107
|
except Exception:
|
|
@@ -1207,6 +1194,13 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1207
1194
|
self.logger.info("Analyzing basis trading opportunities...")
|
|
1208
1195
|
|
|
1209
1196
|
try:
|
|
1197
|
+
# Use actual on-exchange USDC (spot + perp) for sizing when opening a fresh position.
|
|
1198
|
+
# This handles liquidation scenarios where most USDC ends up in spot.
|
|
1199
|
+
perp_margin, spot_usdc = await self._get_undeployed_capital()
|
|
1200
|
+
total_usdc = perp_margin + spot_usdc
|
|
1201
|
+
if total_usdc > 1.0:
|
|
1202
|
+
self.deposit_amount = total_usdc
|
|
1203
|
+
|
|
1210
1204
|
best: dict[str, Any] | None = None
|
|
1211
1205
|
|
|
1212
1206
|
snapshot = self._snapshot_from_config()
|
|
@@ -1350,21 +1344,14 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1350
1344
|
self.logger.warning(f"Failed to set leverage: {lev_result}")
|
|
1351
1345
|
# Continue anyway - leverage might already be set
|
|
1352
1346
|
|
|
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,
|
|
1347
|
+
# Step 3: Ensure USDC is split correctly between spot and perp.
|
|
1348
|
+
# Target: spot has order_usd for the spot buy, perp holds the remainder as margin.
|
|
1349
|
+
split_ok, split_msg = await self._rebalance_usdc_between_perp_and_spot(
|
|
1350
|
+
target_spot_usdc=order_usd,
|
|
1363
1351
|
address=address,
|
|
1364
1352
|
)
|
|
1365
|
-
if not
|
|
1366
|
-
self.logger.warning(f"
|
|
1367
|
-
# May fail if already in spot, continue
|
|
1353
|
+
if not split_ok:
|
|
1354
|
+
self.logger.warning(f"USDC rebalance failed: {split_msg}")
|
|
1368
1355
|
|
|
1369
1356
|
# Step 4: Execute paired fill
|
|
1370
1357
|
filler = PairedFiller(
|
|
@@ -1378,8 +1365,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1378
1365
|
perp_filled,
|
|
1379
1366
|
spot_notional,
|
|
1380
1367
|
perp_notional,
|
|
1381
|
-
|
|
1382
|
-
|
|
1368
|
+
_spot_pointers,
|
|
1369
|
+
_perp_pointers,
|
|
1383
1370
|
) = await filler.fill_pair_units(
|
|
1384
1371
|
coin=coin,
|
|
1385
1372
|
spot_asset_id=spot_asset_id,
|
|
@@ -1488,6 +1475,76 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1488
1475
|
|
|
1489
1476
|
return withdrawable, spot_usdc
|
|
1490
1477
|
|
|
1478
|
+
async def _rebalance_usdc_between_perp_and_spot(
|
|
1479
|
+
self,
|
|
1480
|
+
*,
|
|
1481
|
+
target_spot_usdc: float,
|
|
1482
|
+
address: str,
|
|
1483
|
+
) -> tuple[bool, str]:
|
|
1484
|
+
"""
|
|
1485
|
+
Rebalance Hyperliquid USDC between spot and perp to hit a target spot USDC balance.
|
|
1486
|
+
|
|
1487
|
+
Used before opening/scaling a basis position so we can:
|
|
1488
|
+
- fund the spot buy (spot USDC ~= target_spot_usdc)
|
|
1489
|
+
- keep the remainder in perp as margin (perp USDC ~= total - target_spot_usdc)
|
|
1490
|
+
"""
|
|
1491
|
+
if target_spot_usdc <= 0:
|
|
1492
|
+
return False, "Target spot USDC must be positive"
|
|
1493
|
+
|
|
1494
|
+
perp_margin, spot_usdc = await self._get_undeployed_capital()
|
|
1495
|
+
total_usdc = perp_margin + spot_usdc
|
|
1496
|
+
if total_usdc <= 0:
|
|
1497
|
+
return False, "No deployable USDC on Hyperliquid"
|
|
1498
|
+
|
|
1499
|
+
# Operate at cent precision to avoid dust churn.
|
|
1500
|
+
eps = 0.01
|
|
1501
|
+
target = float(
|
|
1502
|
+
Decimal(str(target_spot_usdc)).quantize(Decimal("0.01"), rounding=ROUND_UP)
|
|
1503
|
+
)
|
|
1504
|
+
|
|
1505
|
+
if target > total_usdc + eps:
|
|
1506
|
+
return (
|
|
1507
|
+
False,
|
|
1508
|
+
f"Target spot ${target:.2f} exceeds total deployable ${total_usdc:.2f}",
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
delta = target - spot_usdc
|
|
1512
|
+
if abs(delta) < eps:
|
|
1513
|
+
return True, "Spot/perp USDC already balanced"
|
|
1514
|
+
|
|
1515
|
+
if delta > 0:
|
|
1516
|
+
# Need more spot USDC: move from perp to spot.
|
|
1517
|
+
amount = float(
|
|
1518
|
+
Decimal(str(min(delta, perp_margin))).quantize(
|
|
1519
|
+
Decimal("0.01"), rounding=ROUND_UP
|
|
1520
|
+
)
|
|
1521
|
+
)
|
|
1522
|
+
if amount < eps:
|
|
1523
|
+
return True, "No meaningful perp->spot transfer needed"
|
|
1524
|
+
success, result = await self.hyperliquid_adapter.transfer_perp_to_spot(
|
|
1525
|
+
amount=amount,
|
|
1526
|
+
address=address,
|
|
1527
|
+
)
|
|
1528
|
+
if not success:
|
|
1529
|
+
return False, f"Perp->spot transfer failed: {result}"
|
|
1530
|
+
return True, f"Transferred ${amount:.2f} perp->spot"
|
|
1531
|
+
|
|
1532
|
+
# Need more perp USDC: move from spot to perp.
|
|
1533
|
+
amount = float(
|
|
1534
|
+
Decimal(str(min(-delta, spot_usdc))).quantize(
|
|
1535
|
+
Decimal("0.01"), rounding=ROUND_DOWN
|
|
1536
|
+
)
|
|
1537
|
+
)
|
|
1538
|
+
if amount < eps:
|
|
1539
|
+
return True, "No meaningful spot->perp transfer needed"
|
|
1540
|
+
success, result = await self.hyperliquid_adapter.transfer_spot_to_perp(
|
|
1541
|
+
amount=amount,
|
|
1542
|
+
address=address,
|
|
1543
|
+
)
|
|
1544
|
+
if not success:
|
|
1545
|
+
return False, f"Spot->perp transfer failed: {result}"
|
|
1546
|
+
return True, f"Transferred ${amount:.2f} spot->perp"
|
|
1547
|
+
|
|
1491
1548
|
async def _scale_up_position(self, additional_capital: float) -> StatusTuple:
|
|
1492
1549
|
"""
|
|
1493
1550
|
Add capital to existing position without breaking it.
|
|
@@ -1553,18 +1610,14 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1553
1610
|
f"(${order_usd:.2f}) at {leverage}x leverage"
|
|
1554
1611
|
)
|
|
1555
1612
|
|
|
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}")
|
|
1613
|
+
# Ensure idle USDC is split correctly between spot and perp for this scale-up.
|
|
1614
|
+
# Target: spot has order_usd for the spot buy, perp holds the remainder as margin.
|
|
1615
|
+
split_ok, split_msg = await self._rebalance_usdc_between_perp_and_spot(
|
|
1616
|
+
target_spot_usdc=order_usd,
|
|
1617
|
+
address=address,
|
|
1618
|
+
)
|
|
1619
|
+
if not split_ok:
|
|
1620
|
+
self.logger.warning(f"USDC rebalance failed: {split_msg}")
|
|
1568
1621
|
|
|
1569
1622
|
# Execute paired fill to add to both legs
|
|
1570
1623
|
filler = PairedFiller(
|
|
@@ -1645,6 +1698,22 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1645
1698
|
# Calculate deposited amount from current on-exchange value
|
|
1646
1699
|
total_value, hl_value, _ = await self._get_total_portfolio_value()
|
|
1647
1700
|
|
|
1701
|
+
# ------------------------------------------------------------------ #
|
|
1702
|
+
# Emergency: Near-liquidation risk management #
|
|
1703
|
+
# ------------------------------------------------------------------ #
|
|
1704
|
+
near_liq, near_msg = await self._is_near_liquidation(state)
|
|
1705
|
+
if near_liq:
|
|
1706
|
+
self.logger.warning(f"Near liquidation on {coin}: {near_msg}")
|
|
1707
|
+
# Close both legs (sell spot, buy perp) and redeploy into a fresh position.
|
|
1708
|
+
# This bypasses rotation cooldown because it's an emergency safety action.
|
|
1709
|
+
close_success, close_msg = await self._close_position()
|
|
1710
|
+
if not close_success:
|
|
1711
|
+
return (
|
|
1712
|
+
False,
|
|
1713
|
+
f"Emergency rebalance failed - could not close: {close_msg}",
|
|
1714
|
+
)
|
|
1715
|
+
return await self._find_and_open_position()
|
|
1716
|
+
|
|
1648
1717
|
# ------------------------------------------------------------------ #
|
|
1649
1718
|
# Check 1: Rebalance needed? #
|
|
1650
1719
|
# ------------------------------------------------------------------ #
|
|
@@ -1728,6 +1797,70 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1728
1797
|
f"Position on {coin} healthy, age: {position_age_hours:.1f}h",
|
|
1729
1798
|
)
|
|
1730
1799
|
|
|
1800
|
+
async def _is_near_liquidation(self, state: dict[str, Any]) -> tuple[bool, str]:
|
|
1801
|
+
"""
|
|
1802
|
+
Check whether the perp leg is too close to liquidation.
|
|
1803
|
+
|
|
1804
|
+
For a short perp, liquidation is ABOVE entry. We measure progress from entry -> liquidation:
|
|
1805
|
+
frac = (mid - entry) / (liq - entry)
|
|
1806
|
+
"""
|
|
1807
|
+
if self.current_position is None:
|
|
1808
|
+
return False, "No position"
|
|
1809
|
+
|
|
1810
|
+
coin = self.current_position.coin
|
|
1811
|
+
|
|
1812
|
+
perp_pos = None
|
|
1813
|
+
for pos_wrapper in state.get("assetPositions", []):
|
|
1814
|
+
pos = pos_wrapper.get("position", {})
|
|
1815
|
+
if pos.get("coin") == coin:
|
|
1816
|
+
perp_pos = pos
|
|
1817
|
+
break
|
|
1818
|
+
|
|
1819
|
+
if not perp_pos:
|
|
1820
|
+
return False, "No perp position found"
|
|
1821
|
+
|
|
1822
|
+
try:
|
|
1823
|
+
szi = float(perp_pos.get("szi", 0) or 0)
|
|
1824
|
+
except (TypeError, ValueError):
|
|
1825
|
+
szi = 0.0
|
|
1826
|
+
|
|
1827
|
+
# Only applies to short perps (basis trade is short perp).
|
|
1828
|
+
if szi >= 0:
|
|
1829
|
+
return False, "Perp is not short"
|
|
1830
|
+
|
|
1831
|
+
entry_px_raw = perp_pos.get("entryPx") or self.current_position.entry_price
|
|
1832
|
+
liq_px_raw = perp_pos.get("liquidationPx")
|
|
1833
|
+
try:
|
|
1834
|
+
entry_px = float(entry_px_raw or 0)
|
|
1835
|
+
liq_px = float(liq_px_raw or 0)
|
|
1836
|
+
except (TypeError, ValueError):
|
|
1837
|
+
return False, "Missing entry/liquidation price"
|
|
1838
|
+
|
|
1839
|
+
if entry_px <= 0 or liq_px <= 0 or liq_px <= entry_px:
|
|
1840
|
+
return False, "Invalid entry/liquidation prices"
|
|
1841
|
+
|
|
1842
|
+
success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
|
|
1843
|
+
if not success:
|
|
1844
|
+
return False, "Failed to fetch mid prices"
|
|
1845
|
+
|
|
1846
|
+
mid_px = float(mids.get(coin, 0.0) or 0.0)
|
|
1847
|
+
if mid_px <= 0:
|
|
1848
|
+
return False, "Missing mid price"
|
|
1849
|
+
|
|
1850
|
+
denom = liq_px - entry_px
|
|
1851
|
+
frac = (mid_px - entry_px) / denom if denom != 0 else 0.0
|
|
1852
|
+
|
|
1853
|
+
if frac >= self.LIQUIDATION_REBALANCE_THRESHOLD:
|
|
1854
|
+
return (
|
|
1855
|
+
True,
|
|
1856
|
+
f"mid=${mid_px:.4f} is {frac:.2%} of the way from entry=${entry_px:.4f} to liq=${liq_px:.4f}",
|
|
1857
|
+
)
|
|
1858
|
+
|
|
1859
|
+
return (
|
|
1860
|
+
False,
|
|
1861
|
+
f"mid=${mid_px:.4f} is {frac:.2%} of the way from entry=${entry_px:.4f} to liq=${liq_px:.4f}",
|
|
1862
|
+
)
|
|
1863
|
+
|
|
1731
1864
|
async def _verify_leg_balance(self, state: dict[str, Any]) -> tuple[bool, str]:
|
|
1732
1865
|
"""
|
|
1733
1866
|
Verify that spot and perp legs are balanced (delta neutral).
|
|
@@ -2040,7 +2173,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2040
2173
|
"""
|
|
2041
2174
|
Check if current delta-neutral position needs rebalancing.
|
|
2042
2175
|
|
|
2043
|
-
Implements
|
|
2176
|
+
Implements the following health checks:
|
|
2044
2177
|
1. Missing positions
|
|
2045
2178
|
2. Asset mismatch (if best specified)
|
|
2046
2179
|
3. Funding accumulation threshold
|
|
@@ -2146,74 +2279,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2146
2279
|
|
|
2147
2280
|
return 0.0
|
|
2148
2281
|
|
|
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
2282
|
async def _place_stop_loss_orders(
|
|
2218
2283
|
self,
|
|
2219
2284
|
coin: str,
|
|
@@ -2503,7 +2568,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2503
2568
|
try:
|
|
2504
2569
|
strategy_address = self._get_strategy_wallet_address()
|
|
2505
2570
|
success, balance = await self.balance_adapter.get_balance(
|
|
2506
|
-
|
|
2571
|
+
query=USDC_ARBITRUM_TOKEN_ID,
|
|
2507
2572
|
wallet_address=strategy_address,
|
|
2508
2573
|
)
|
|
2509
2574
|
if success and balance:
|
|
@@ -2515,132 +2580,9 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2515
2580
|
return total_value, hl_value, strategy_wallet_value
|
|
2516
2581
|
|
|
2517
2582
|
# ------------------------------------------------------------------ #
|
|
2518
|
-
# Analysis Methods
|
|
2583
|
+
# Analysis Methods #
|
|
2519
2584
|
# ------------------------------------------------------------------ #
|
|
2520
2585
|
|
|
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
2586
|
def _find_basis_candidates(
|
|
2645
2587
|
self,
|
|
2646
2588
|
spot_pairs: list[dict],
|
|
@@ -2775,249 +2717,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2775
2717
|
|
|
2776
2718
|
return liquid
|
|
2777
2719
|
|
|
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
2720
|
# ------------------------------------------------------------------ #
|
|
3022
2721
|
# Chunked Data Fetching #
|
|
3023
2722
|
# ------------------------------------------------------------------ #
|
|
@@ -3177,7 +2876,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3177
2876
|
return True, all_candles
|
|
3178
2877
|
|
|
3179
2878
|
# ------------------------------------------------------------------ #
|
|
3180
|
-
# Net APY Solver + Bootstrap
|
|
2879
|
+
# Net APY Solver + Bootstrap #
|
|
3181
2880
|
# ------------------------------------------------------------------ #
|
|
3182
2881
|
|
|
3183
2882
|
def _spot_index_from_asset_id(self, spot_asset_id: int) -> int:
|
|
@@ -3904,16 +3603,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3904
3603
|
|
|
3905
3604
|
return worst_req if worst_req > 0 else float(fallback_mmr + fee_eps)
|
|
3906
3605
|
|
|
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
3606
|
def _size_step(self, asset_id: int) -> Decimal:
|
|
3918
3607
|
try:
|
|
3919
3608
|
mapping = self.hyperliquid_adapter.asset_to_sz_decimals
|
|
@@ -4441,15 +4130,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
4441
4130
|
return 0.5
|
|
4442
4131
|
return 0.5 / max_lev
|
|
4443
4132
|
|
|
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
4133
|
def _get_strategy_wallet_address(self) -> str:
|
|
4454
4134
|
"""Get strategy wallet address from config."""
|
|
4455
4135
|
strategy_wallet = self.config.get("strategy_wallet")
|