wayfinder-paths 0.1.19__py3-none-any.whl → 0.1.21__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/__init__.py +0 -2
- wayfinder_paths/adapters/balance_adapter/README.md +59 -45
- wayfinder_paths/adapters/balance_adapter/adapter.py +1 -22
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -14
- wayfinder_paths/adapters/brap_adapter/README.md +61 -184
- wayfinder_paths/adapters/brap_adapter/__init__.py +0 -4
- wayfinder_paths/adapters/brap_adapter/adapter.py +1 -148
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +0 -15
- wayfinder_paths/adapters/hyperlend_adapter/__init__.py +0 -4
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +1 -10
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +0 -17
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +3 -312
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +1 -71
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +0 -57
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +0 -17
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +2 -42
- wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +1 -9
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +15 -47
- wayfinder_paths/adapters/hyperliquid_adapter/utils.py +0 -7
- wayfinder_paths/adapters/ledger_adapter/README.md +54 -74
- wayfinder_paths/adapters/ledger_adapter/__init__.py +0 -4
- wayfinder_paths/adapters/ledger_adapter/adapter.py +0 -106
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +0 -12
- wayfinder_paths/adapters/moonwell_adapter/README.md +67 -106
- wayfinder_paths/adapters/moonwell_adapter/__init__.py +0 -4
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +10 -122
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +84 -83
- wayfinder_paths/adapters/pool_adapter/README.md +30 -51
- wayfinder_paths/adapters/pool_adapter/__init__.py +0 -4
- wayfinder_paths/adapters/pool_adapter/adapter.py +0 -19
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +0 -8
- wayfinder_paths/adapters/token_adapter/README.md +41 -49
- wayfinder_paths/adapters/token_adapter/adapter.py +0 -32
- wayfinder_paths/adapters/token_adapter/test_adapter.py +1 -12
- wayfinder_paths/conftest.py +0 -8
- wayfinder_paths/core/__init__.py +0 -2
- wayfinder_paths/core/adapters/BaseAdapter.py +0 -22
- wayfinder_paths/core/adapters/__init__.py +0 -5
- wayfinder_paths/core/adapters/models.py +0 -5
- wayfinder_paths/core/analytics/__init__.py +0 -2
- wayfinder_paths/core/analytics/bootstrap.py +0 -16
- wayfinder_paths/core/analytics/stats.py +0 -7
- wayfinder_paths/core/analytics/test_analytics.py +5 -34
- wayfinder_paths/core/clients/BRAPClient.py +0 -35
- wayfinder_paths/core/clients/ClientManager.py +0 -51
- wayfinder_paths/core/clients/HyperlendClient.py +0 -77
- wayfinder_paths/core/clients/LedgerClient.py +2 -122
- wayfinder_paths/core/clients/PoolClient.py +0 -2
- wayfinder_paths/core/clients/TokenClient.py +0 -39
- wayfinder_paths/core/clients/WalletClient.py +0 -15
- wayfinder_paths/core/clients/WayfinderClient.py +0 -24
- wayfinder_paths/core/clients/__init__.py +0 -4
- wayfinder_paths/core/clients/protocols.py +25 -98
- wayfinder_paths/core/config.py +0 -24
- wayfinder_paths/core/constants/__init__.py +0 -7
- wayfinder_paths/core/constants/base.py +2 -9
- wayfinder_paths/core/constants/erc20_abi.py +0 -5
- wayfinder_paths/core/constants/hyperlend_abi.py +0 -7
- wayfinder_paths/core/constants/moonwell_abi.py +0 -35
- wayfinder_paths/core/engine/StrategyJob.py +0 -32
- wayfinder_paths/core/strategies/Strategy.py +0 -99
- wayfinder_paths/core/strategies/__init__.py +0 -2
- wayfinder_paths/core/utils/__init__.py +0 -1
- wayfinder_paths/core/utils/evm_helpers.py +0 -50
- wayfinder_paths/core/utils/{erc20_service.py → tokens.py} +25 -21
- wayfinder_paths/core/utils/transaction.py +0 -1
- wayfinder_paths/run_strategy.py +0 -46
- wayfinder_paths/scripts/create_strategy.py +0 -17
- wayfinder_paths/scripts/make_wallets.py +1 -4
- wayfinder_paths/strategies/basis_trading_strategy/README.md +71 -163
- wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +0 -24
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +36 -400
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +15 -64
- wayfinder_paths/strategies/basis_trading_strategy/types.py +0 -4
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +65 -56
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +4 -27
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +0 -10
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +71 -72
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +23 -227
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +120 -113
- wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +64 -59
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +4 -44
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +2 -35
- wayfinder_paths/templates/adapter/README.md +107 -46
- wayfinder_paths/templates/adapter/adapter.py +0 -9
- wayfinder_paths/templates/adapter/test_adapter.py +0 -19
- wayfinder_paths/templates/strategy/README.md +113 -59
- wayfinder_paths/templates/strategy/strategy.py +0 -22
- wayfinder_paths/templates/strategy/test_strategy.py +0 -28
- wayfinder_paths/tests/test_test_coverage.py +2 -12
- wayfinder_paths/tests/test_utils.py +1 -31
- wayfinder_paths-0.1.21.dist-info/METADATA +355 -0
- wayfinder_paths-0.1.21.dist-info/RECORD +129 -0
- {wayfinder_paths-0.1.19.dist-info → wayfinder_paths-0.1.21.dist-info}/WHEEL +1 -1
- wayfinder_paths/core/adapters/base.py +0 -5
- wayfinder_paths-0.1.19.dist-info/METADATA +0 -592
- wayfinder_paths-0.1.19.dist-info/RECORD +0 -130
- {wayfinder_paths-0.1.19.dist-info → wayfinder_paths-0.1.21.dist-info}/LICENSE +0 -0
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
"""
|
|
2
|
-
BasisTradingStrategy - Delta-neutral basis trading on Hyperliquid.
|
|
3
|
-
|
|
4
|
-
Identifies and executes basis trading opportunities by pairing spot long
|
|
5
|
-
positions with perpetual short positions to capture funding rate payments.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
1
|
from __future__ import annotations
|
|
9
2
|
|
|
10
3
|
import asyncio
|
|
@@ -81,34 +74,24 @@ from wayfinder_paths.strategies.basis_trading_strategy.types import (
|
|
|
81
74
|
BasisPosition,
|
|
82
75
|
)
|
|
83
76
|
|
|
84
|
-
# Set decimal precision for precise price/size calculations
|
|
85
77
|
getcontext().prec = 28
|
|
86
78
|
|
|
87
79
|
|
|
88
80
|
def _d(x: float | Decimal | str) -> Decimal:
|
|
89
|
-
"""Convert to Decimal for precise calculations."""
|
|
90
81
|
return x if isinstance(x, Decimal) else Decimal(str(x))
|
|
91
82
|
|
|
92
83
|
|
|
93
84
|
class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
94
|
-
"""
|
|
95
|
-
Delta-neutral basis trading strategy on Hyperliquid.
|
|
96
|
-
|
|
97
|
-
Captures funding rate payments by maintaining offsetting spot long and
|
|
98
|
-
perpetual short positions. Uses historical funding rate and volatility
|
|
99
|
-
analysis to select optimal opportunities.
|
|
100
|
-
"""
|
|
101
|
-
|
|
102
85
|
name = "Basis Trading Strategy"
|
|
103
86
|
|
|
104
87
|
# Strategy parameters
|
|
105
88
|
MIN_DEPOSIT_USDC = 25
|
|
106
|
-
DEFAULT_LOOKBACK_DAYS = 30
|
|
107
|
-
DEFAULT_FEE_EPS = 0.003
|
|
108
|
-
DEFAULT_OI_FLOOR = 100_000.0
|
|
109
|
-
DEFAULT_DAY_VLM_FLOOR = 100_000
|
|
89
|
+
DEFAULT_LOOKBACK_DAYS = 30
|
|
90
|
+
DEFAULT_FEE_EPS = 0.003
|
|
91
|
+
DEFAULT_OI_FLOOR = 100_000.0
|
|
92
|
+
DEFAULT_DAY_VLM_FLOOR = 100_000
|
|
110
93
|
DEFAULT_MAX_LEVERAGE = 2
|
|
111
|
-
GAS_MAXIMUM = 0.01
|
|
94
|
+
GAS_MAXIMUM = 0.01
|
|
112
95
|
DEFAULT_BOOTSTRAP_SIMS = 50
|
|
113
96
|
DEFAULT_BOOTSTRAP_BLOCK_HOURS = 48
|
|
114
97
|
|
|
@@ -117,19 +100,18 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
117
100
|
LIQUIDATION_REBALANCE_THRESHOLD = 0.75
|
|
118
101
|
# Stop-loss at 90% to liquidation (closer)
|
|
119
102
|
LIQUIDATION_STOP_LOSS_THRESHOLD = 0.90
|
|
120
|
-
FUNDING_REBALANCE_THRESHOLD = 0.02
|
|
103
|
+
FUNDING_REBALANCE_THRESHOLD = 0.02
|
|
121
104
|
|
|
122
105
|
# Position tolerances
|
|
123
|
-
SPOT_POSITION_DUST_TOLERANCE = 0.04
|
|
124
|
-
MIN_UNUSED_USD = 5.0
|
|
125
|
-
UNUSED_REL_EPS = 0.01
|
|
106
|
+
SPOT_POSITION_DUST_TOLERANCE = 0.04
|
|
107
|
+
MIN_UNUSED_USD = 5.0
|
|
108
|
+
UNUSED_REL_EPS = 0.01
|
|
126
109
|
|
|
127
110
|
# Rotation cooldown
|
|
128
|
-
ROTATION_MIN_INTERVAL_DAYS = 14
|
|
111
|
+
ROTATION_MIN_INTERVAL_DAYS = 14
|
|
129
112
|
|
|
130
|
-
# Builder fee for Hyperliquid trades
|
|
131
113
|
HYPE_FEE_WALLET: str = "0xaA1D89f333857eD78F8434CC4f896A9293EFE65c"
|
|
132
|
-
HYPE_PRO_FEE: int = 30
|
|
114
|
+
HYPE_PRO_FEE: int = 30
|
|
133
115
|
DEFAULT_BUILDER_FEE: dict[str, Any] = {"b": HYPE_FEE_WALLET, "f": HYPE_PRO_FEE}
|
|
134
116
|
|
|
135
117
|
INFO = StratDescriptor(
|
|
@@ -223,8 +205,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
223
205
|
self.current_position: BasisPosition | None = None
|
|
224
206
|
self.deposit_amount: float = 0.0
|
|
225
207
|
|
|
226
|
-
# Builder fee for Hyperliquid trades (from config or default)
|
|
227
|
-
# Format: {"b": "0x...", "f": 10} where 'b' is address, 'f' is fee in bps
|
|
228
208
|
self.builder_fee: dict[str, Any] | None = self.config.get(
|
|
229
209
|
"builder_fee", self.DEFAULT_BUILDER_FEE
|
|
230
210
|
)
|
|
@@ -243,7 +223,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
243
223
|
"strategy": self.config,
|
|
244
224
|
}
|
|
245
225
|
|
|
246
|
-
# Create Hyperliquid executor if not provided.
|
|
247
226
|
# This is only required for placing/canceling orders (not market reads).
|
|
248
227
|
hl_executor = hyperliquid_executor
|
|
249
228
|
if hl_executor is None:
|
|
@@ -291,13 +270,11 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
291
270
|
self.register_adapters(adapters)
|
|
292
271
|
|
|
293
272
|
async def setup(self) -> None:
|
|
294
|
-
"""Initialize strategy state from chain/ledger and discover existing positions."""
|
|
295
273
|
self.logger.info("Starting BasisTradingStrategy setup")
|
|
296
274
|
start_time = time.time()
|
|
297
275
|
|
|
298
276
|
await super().setup()
|
|
299
277
|
|
|
300
|
-
# Get net deposit from ledger
|
|
301
278
|
try:
|
|
302
279
|
success, deposit_data = await self.ledger_adapter.get_strategy_net_deposit(
|
|
303
280
|
wallet_address=self._get_strategy_wallet_address()
|
|
@@ -317,15 +294,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
317
294
|
self.logger.info(f"BasisTradingStrategy setup completed in {elapsed:.2f}s")
|
|
318
295
|
|
|
319
296
|
async def _discover_existing_position(self) -> None:
|
|
320
|
-
"""
|
|
321
|
-
Discover existing delta-neutral position from Hyperliquid state.
|
|
322
|
-
|
|
323
|
-
This is critical for restart recovery - we must not open new positions
|
|
324
|
-
if one already exists on-chain.
|
|
325
|
-
"""
|
|
326
297
|
address = self._get_strategy_wallet_address()
|
|
327
298
|
|
|
328
|
-
# Get perp positions
|
|
329
299
|
success, user_state = await self.hyperliquid_adapter.get_user_state(address)
|
|
330
300
|
if not success:
|
|
331
301
|
self.logger.warning("Could not fetch user state for position discovery")
|
|
@@ -341,7 +311,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
341
311
|
for pos_wrapper in asset_positions:
|
|
342
312
|
pos = pos_wrapper.get("position", {})
|
|
343
313
|
szi = float(pos.get("szi", 0))
|
|
344
|
-
if szi < 0:
|
|
314
|
+
if szi < 0:
|
|
345
315
|
perp_position = pos
|
|
346
316
|
break
|
|
347
317
|
|
|
@@ -353,7 +323,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
353
323
|
perp_size = abs(float(perp_position.get("szi", 0)))
|
|
354
324
|
entry_px = float(perp_position.get("entryPx", 0))
|
|
355
325
|
|
|
356
|
-
# Get spot positions to find matching spot leg
|
|
357
326
|
success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
|
|
358
327
|
address
|
|
359
328
|
)
|
|
@@ -389,7 +358,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
389
358
|
else:
|
|
390
359
|
spot_size = float(spot_position.get("total", 0))
|
|
391
360
|
|
|
392
|
-
# Get asset IDs
|
|
393
361
|
perp_asset_id = self.hyperliquid_adapter.coin_to_asset.get(coin)
|
|
394
362
|
# Spot asset ID: look up from spot meta or estimate
|
|
395
363
|
spot_asset_id = None
|
|
@@ -401,7 +369,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
401
369
|
base_idx = pair["tokens"][0]
|
|
402
370
|
for t in tokens:
|
|
403
371
|
if t["index"] == base_idx:
|
|
404
|
-
# Check if this token matches our coin
|
|
405
372
|
if (
|
|
406
373
|
t["name"] == coin
|
|
407
374
|
or t["name"] == f"U{coin}"
|
|
@@ -420,14 +387,13 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
420
387
|
spot_amount=spot_size,
|
|
421
388
|
perp_amount=perp_size,
|
|
422
389
|
entry_price=entry_px,
|
|
423
|
-
leverage=2,
|
|
424
|
-
entry_timestamp=int(time.time() * 1000),
|
|
390
|
+
leverage=2,
|
|
391
|
+
entry_timestamp=int(time.time() * 1000),
|
|
425
392
|
funding_collected=abs(
|
|
426
393
|
float(perp_position.get("cumFunding", {}).get("sinceOpen", 0))
|
|
427
394
|
),
|
|
428
395
|
)
|
|
429
396
|
|
|
430
|
-
# Update deposit amount from actual account value if not set
|
|
431
397
|
if self.deposit_amount <= 0:
|
|
432
398
|
margin_summary = user_state.get("marginSummary", {})
|
|
433
399
|
self.deposit_amount = float(margin_summary.get("accountValue", 0))
|
|
@@ -442,19 +408,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
442
408
|
main_token_amount: float = 0.0,
|
|
443
409
|
gas_token_amount: float = 0.0,
|
|
444
410
|
) -> StatusTuple:
|
|
445
|
-
"""
|
|
446
|
-
Deposit USDC to Hyperliquid L1 for basis trading.
|
|
447
|
-
|
|
448
|
-
Sends USDC from the strategy wallet to the Hyperliquid bridge address on Arbitrum,
|
|
449
|
-
then waits for it to be credited on Hyperliquid L1.
|
|
450
|
-
|
|
451
|
-
Args:
|
|
452
|
-
main_token_amount: Amount of USDC to deposit
|
|
453
|
-
gas_token_amount: Amount of ETH for gas (unused, kept for interface compatibility)
|
|
454
|
-
|
|
455
|
-
Returns:
|
|
456
|
-
StatusTuple (success, message)
|
|
457
|
-
"""
|
|
458
411
|
if main_token_amount < self.MIN_DEPOSIT_USDC:
|
|
459
412
|
return (False, f"Minimum deposit is {self.MIN_DEPOSIT_USDC} USDC")
|
|
460
413
|
|
|
@@ -475,7 +428,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
475
428
|
gas_ok,
|
|
476
429
|
gas_res,
|
|
477
430
|
) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
|
|
478
|
-
token_id="ethereum-arbitrum",
|
|
431
|
+
token_id="ethereum-arbitrum",
|
|
479
432
|
amount=gas_token_amount,
|
|
480
433
|
strategy_name=self.name or "basis_trading_strategy",
|
|
481
434
|
)
|
|
@@ -489,7 +442,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
489
442
|
main_address = self._get_main_wallet_address()
|
|
490
443
|
strategy_address = self._get_strategy_wallet_address()
|
|
491
444
|
|
|
492
|
-
# Check if strategy wallet already has sufficient USDC
|
|
493
445
|
(
|
|
494
446
|
strategy_balance_ok,
|
|
495
447
|
strategy_balance,
|
|
@@ -544,24 +496,9 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
544
496
|
return (False, f"Deposit failed: {e}")
|
|
545
497
|
|
|
546
498
|
async def update(self) -> StatusTuple:
|
|
547
|
-
"""
|
|
548
|
-
Analyze markets and manage positions.
|
|
549
|
-
|
|
550
|
-
- If no position exists, analyzes opportunities and opens the best one.
|
|
551
|
-
- If position exists, monitors and maintains it:
|
|
552
|
-
- Checks for rebalance conditions
|
|
553
|
-
- Verifies leg balance (spot == perp)
|
|
554
|
-
- Deploys any idle capital
|
|
555
|
-
- Ensures stop-loss orders are valid
|
|
556
|
-
|
|
557
|
-
Returns:
|
|
558
|
-
StatusTuple (success, message)
|
|
559
|
-
"""
|
|
560
|
-
# Check actual balances instead of relying on in-memory deposit_amount
|
|
561
499
|
strategy_address = self._get_strategy_wallet_address()
|
|
562
500
|
strategy_wallet = self.config.get("strategy_wallet")
|
|
563
501
|
|
|
564
|
-
# Check strategy wallet USDC balance on Arbitrum
|
|
565
502
|
strategy_usdc = 0.0
|
|
566
503
|
try:
|
|
567
504
|
(
|
|
@@ -576,7 +513,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
576
513
|
except Exception as e:
|
|
577
514
|
self.logger.warning(f"Could not check strategy wallet balance: {e}")
|
|
578
515
|
|
|
579
|
-
# Check Hyperliquid USDC balance (spot + perp)
|
|
580
516
|
hl_usdc = 0.0
|
|
581
517
|
try:
|
|
582
518
|
perp_margin, spot_usdc = await self._get_undeployed_capital()
|
|
@@ -584,7 +520,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
584
520
|
except Exception as e:
|
|
585
521
|
self.logger.warning(f"Could not check Hyperliquid balance: {e}")
|
|
586
522
|
|
|
587
|
-
# Update deposit_amount from actual balances
|
|
588
523
|
total_available = strategy_usdc + hl_usdc
|
|
589
524
|
if total_available > 1.0:
|
|
590
525
|
self.deposit_amount = max(self.deposit_amount, total_available)
|
|
@@ -599,7 +534,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
599
534
|
f"Found ${strategy_usdc:.2f} USDC in strategy wallet, bridging to Hyperliquid"
|
|
600
535
|
)
|
|
601
536
|
|
|
602
|
-
# Send USDC to bridge address (internal operation, not a deposit event)
|
|
603
537
|
success, result = await self.balance_adapter.send_to_address(
|
|
604
538
|
token_id=USDC_ARBITRUM_TOKEN_ID,
|
|
605
539
|
amount=strategy_usdc,
|
|
@@ -622,7 +556,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
622
556
|
) = await self.hyperliquid_adapter.wait_for_deposit(
|
|
623
557
|
address=strategy_address,
|
|
624
558
|
expected_increase=strategy_usdc,
|
|
625
|
-
timeout_s=180,
|
|
559
|
+
timeout_s=180,
|
|
626
560
|
poll_interval_s=10,
|
|
627
561
|
)
|
|
628
562
|
|
|
@@ -654,19 +588,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
654
588
|
async def analyze(
|
|
655
589
|
self, deposit_usdc: float = 1000.0, verbose: bool = True
|
|
656
590
|
) -> dict[str, Any]:
|
|
657
|
-
"""
|
|
658
|
-
Analyze basis trading opportunities without executing.
|
|
659
|
-
|
|
660
|
-
Uses the Net-APY + stop-churn backtest solver with block-bootstrap
|
|
661
|
-
resampling.
|
|
662
|
-
|
|
663
|
-
Args:
|
|
664
|
-
deposit_usdc: Hypothetical deposit amount for sizing calculations (default $1000)
|
|
665
|
-
verbose: Include debug info about filtering
|
|
666
|
-
|
|
667
|
-
Returns:
|
|
668
|
-
Dict with opportunities sorted by net APY (includes bootstrap metrics)
|
|
669
|
-
"""
|
|
670
591
|
self.logger.info(
|
|
671
592
|
f"Analyzing basis opportunities for ${deposit_usdc} deposit..."
|
|
672
593
|
)
|
|
@@ -718,7 +639,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
718
639
|
"Falling back to live analysis."
|
|
719
640
|
)
|
|
720
641
|
|
|
721
|
-
# Get market data for debug info
|
|
722
642
|
(
|
|
723
643
|
success,
|
|
724
644
|
perps_ctx_pack,
|
|
@@ -793,12 +713,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
793
713
|
}
|
|
794
714
|
|
|
795
715
|
def _cfg_get(self, key: str, default: Any | None = None) -> Any:
|
|
796
|
-
"""
|
|
797
|
-
Read a strategy config value.
|
|
798
|
-
|
|
799
|
-
Supports both flat configs (common in this repo) and nested configs
|
|
800
|
-
where strategy settings live under a "strategy" key.
|
|
801
|
-
"""
|
|
802
716
|
if key in self.config:
|
|
803
717
|
return self.config.get(key, default)
|
|
804
718
|
nested = self.config.get("strategy")
|
|
@@ -807,11 +721,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
807
721
|
return default
|
|
808
722
|
|
|
809
723
|
def _resolve_mid_price(self, coin: str, mid_prices: dict[str, float]) -> float:
|
|
810
|
-
"""Resolve mid price with U-prefix handling for universal spot tokens.
|
|
811
|
-
|
|
812
|
-
Spot balances may return U-prefixed coin names (e.g., UXPL) while mid prices
|
|
813
|
-
are keyed by non-prefixed names (e.g., XPL). This helper handles the mismatch.
|
|
814
|
-
"""
|
|
815
724
|
# Direct match
|
|
816
725
|
if coin in mid_prices:
|
|
817
726
|
return mid_prices[coin]
|
|
@@ -828,7 +737,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
828
737
|
if key in mid_prices:
|
|
829
738
|
return mid_prices[key]
|
|
830
739
|
|
|
831
|
-
# Add U-prefix (XPL -> UXPL)
|
|
832
740
|
prefixed = f"U{coin}"
|
|
833
741
|
for key in [prefixed, prefixed.upper(), prefixed.lower()]:
|
|
834
742
|
if key in mid_prices:
|
|
@@ -837,7 +745,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
837
745
|
return 0.0
|
|
838
746
|
|
|
839
747
|
def _coins_match(self, coin1: str, coin2: str) -> bool:
|
|
840
|
-
"""Check if two coin names match, handling U-prefix (UXPL == XPL)."""
|
|
841
748
|
if coin1 == coin2:
|
|
842
749
|
return True
|
|
843
750
|
# Strip U-prefix from either and compare
|
|
@@ -846,25 +753,9 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
846
753
|
return c1 == c2
|
|
847
754
|
|
|
848
755
|
async def withdraw(self, amount: float | None = None) -> StatusTuple:
|
|
849
|
-
"""
|
|
850
|
-
Close all positions and liquidate to strategy wallet.
|
|
851
|
-
|
|
852
|
-
Handles funds in:
|
|
853
|
-
1. Strategy wallet on Arbitrum (USDC)
|
|
854
|
-
2. Hyperliquid L1 (positions + margin)
|
|
855
|
-
|
|
856
|
-
Does NOT transfer to main wallet - call exit() for that.
|
|
857
|
-
|
|
858
|
-
Args:
|
|
859
|
-
amount: Amount to withdraw (None = all)
|
|
860
|
-
|
|
861
|
-
Returns:
|
|
862
|
-
StatusTuple (success, message)
|
|
863
|
-
"""
|
|
864
756
|
address = self._get_strategy_wallet_address()
|
|
865
757
|
usdc_token_id = "usd-coin-arbitrum"
|
|
866
758
|
|
|
867
|
-
# Check for USDC already in strategy wallet on Arbitrum
|
|
868
759
|
strategy_usdc = 0.0
|
|
869
760
|
try:
|
|
870
761
|
success, balance_data = await self.balance_adapter.get_balance(
|
|
@@ -872,11 +763,10 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
872
763
|
wallet_address=address,
|
|
873
764
|
)
|
|
874
765
|
if success:
|
|
875
|
-
strategy_usdc = float(balance_data) / 1e6
|
|
766
|
+
strategy_usdc = float(balance_data) / 1e6
|
|
876
767
|
except Exception as e:
|
|
877
768
|
self.logger.warning(f"Could not get strategy wallet balance: {e}")
|
|
878
769
|
|
|
879
|
-
# Get current Hyperliquid value (perp + spot)
|
|
880
770
|
hl_perp_value = 0.0
|
|
881
771
|
hl_spot_usdc = 0.0
|
|
882
772
|
success, user_state = await self.hyperliquid_adapter.get_user_state(address)
|
|
@@ -896,7 +786,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
896
786
|
|
|
897
787
|
hl_value = hl_perp_value + hl_spot_usdc
|
|
898
788
|
|
|
899
|
-
# Check if there's anything to withdraw
|
|
900
789
|
if strategy_usdc < 1.0 and hl_value < 1.0 and self.current_position is None:
|
|
901
790
|
return (False, "Nothing to withdraw")
|
|
902
791
|
|
|
@@ -937,7 +826,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
937
826
|
# Floor to 2 decimal places to avoid precision issues
|
|
938
827
|
spot_usdc = math.floor(available * 100) / 100
|
|
939
828
|
|
|
940
|
-
if spot_usdc > 1.0:
|
|
829
|
+
if spot_usdc > 1.0:
|
|
941
830
|
self.logger.info(
|
|
942
831
|
f"Transferring ${spot_usdc:.2f} from spot to perp "
|
|
943
832
|
f"(available={available:.8f}, floored={spot_usdc:.2f})"
|
|
@@ -1009,7 +898,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1009
898
|
) = await self.hyperliquid_adapter.wait_for_withdrawal(
|
|
1010
899
|
address=address,
|
|
1011
900
|
lookback_s=5,
|
|
1012
|
-
max_poll_time_s=20 * 60,
|
|
901
|
+
max_poll_time_s=20 * 60,
|
|
1013
902
|
poll_interval_s=10,
|
|
1014
903
|
)
|
|
1015
904
|
|
|
@@ -1020,7 +909,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1020
909
|
"Check Hyperliquid explorer for status.",
|
|
1021
910
|
)
|
|
1022
911
|
|
|
1023
|
-
# Get the withdrawal amount from the most recent tx
|
|
1024
912
|
tx_hash = list(withdrawals.keys())[-1]
|
|
1025
913
|
withdrawn_amount = withdrawals[tx_hash]
|
|
1026
914
|
self.logger.info(
|
|
@@ -1030,7 +918,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1030
918
|
# Step 5: Wait a bit for the USDC to be credited on Arbitrum
|
|
1031
919
|
await asyncio.sleep(10)
|
|
1032
920
|
|
|
1033
|
-
# Get final USDC balance in strategy wallet
|
|
1034
921
|
final_balance = 0.0
|
|
1035
922
|
try:
|
|
1036
923
|
success, balance_data = await self.balance_adapter.get_balance(
|
|
@@ -1038,7 +925,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1038
925
|
wallet_address=address,
|
|
1039
926
|
)
|
|
1040
927
|
if success:
|
|
1041
|
-
final_balance = float(balance_data) / 1e6
|
|
928
|
+
final_balance = float(balance_data) / 1e6
|
|
1042
929
|
except Exception as e:
|
|
1043
930
|
self.logger.warning(f"Could not get final balance: {e}")
|
|
1044
931
|
|
|
@@ -1052,7 +939,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1052
939
|
)
|
|
1053
940
|
|
|
1054
941
|
async def exit(self, **kwargs) -> StatusTuple:
|
|
1055
|
-
"""Transfer funds from strategy wallet to main wallet."""
|
|
1056
942
|
self.logger.info("EXIT: Transferring remaining funds to main wallet")
|
|
1057
943
|
|
|
1058
944
|
strategy_address = self._get_strategy_wallet_address()
|
|
@@ -1119,7 +1005,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1119
1005
|
return (True, f"Transferred to main wallet: {', '.join(transferred_items)}")
|
|
1120
1006
|
|
|
1121
1007
|
async def _status(self) -> StatusDict:
|
|
1122
|
-
"""Return portfolio value and strategy status with live data."""
|
|
1123
1008
|
total_value, hl_value, vault_value = await self._get_total_portfolio_value()
|
|
1124
1009
|
|
|
1125
1010
|
status_payload: dict[str, Any] = {
|
|
@@ -1140,7 +1025,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1140
1025
|
}
|
|
1141
1026
|
)
|
|
1142
1027
|
|
|
1143
|
-
# Get net deposit from ledger
|
|
1144
1028
|
try:
|
|
1145
1029
|
success, deposit_data = await self.ledger_adapter.get_strategy_net_deposit(
|
|
1146
1030
|
wallet_address=self._get_strategy_wallet_address()
|
|
@@ -1163,20 +1047,10 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1163
1047
|
|
|
1164
1048
|
@staticmethod
|
|
1165
1049
|
async def policies() -> list[str]:
|
|
1166
|
-
"""Return wallet permission policies."""
|
|
1167
1050
|
# Placeholder - would include Hyperliquid-specific policies
|
|
1168
1051
|
return []
|
|
1169
1052
|
|
|
1170
1053
|
async def ensure_builder_fee_approved(self) -> StatusTuple:
|
|
1171
|
-
"""
|
|
1172
|
-
Ensure the builder fee is approved before trading.
|
|
1173
|
-
|
|
1174
|
-
Checks the current max builder fee approval for the user/builder pair.
|
|
1175
|
-
If the current approval is less than required, submits an approval transaction.
|
|
1176
|
-
|
|
1177
|
-
Returns:
|
|
1178
|
-
StatusTuple (success, message)
|
|
1179
|
-
"""
|
|
1180
1054
|
if not self.builder_fee:
|
|
1181
1055
|
return True, "No builder fee configured"
|
|
1182
1056
|
|
|
@@ -1188,7 +1062,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1188
1062
|
return True, "Builder fee not required"
|
|
1189
1063
|
|
|
1190
1064
|
try:
|
|
1191
|
-
# Check current approval
|
|
1192
1065
|
success, current_fee = await self.hyperliquid_adapter.get_max_builder_fee(
|
|
1193
1066
|
user=address,
|
|
1194
1067
|
builder=builder,
|
|
@@ -1208,7 +1081,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1208
1081
|
return True, f"Builder fee already approved: {current_fee}"
|
|
1209
1082
|
|
|
1210
1083
|
# Need to approve
|
|
1211
|
-
# Convert fee to percentage string (e.g., 30 tenths bp = 0.030%)
|
|
1212
1084
|
max_fee_rate = f"{required_fee / 1000:.3f}%"
|
|
1213
1085
|
self.logger.info(
|
|
1214
1086
|
f"Approving builder fee: builder={builder}, rate={max_fee_rate}"
|
|
@@ -1236,7 +1108,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1236
1108
|
# ------------------------------------------------------------------ #
|
|
1237
1109
|
|
|
1238
1110
|
async def _find_and_open_position(self) -> StatusTuple:
|
|
1239
|
-
"""Analyze markets and open the best basis position."""
|
|
1240
1111
|
self.logger.info("Analyzing basis trading opportunities...")
|
|
1241
1112
|
|
|
1242
1113
|
try:
|
|
@@ -1430,7 +1301,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1430
1301
|
f"notional=${spot_notional:.2f}/${perp_notional:.2f}"
|
|
1431
1302
|
)
|
|
1432
1303
|
|
|
1433
|
-
# Get entry price from current mid
|
|
1434
1304
|
success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
|
|
1435
1305
|
entry_price = self._resolve_mid_price(coin, mids) if success else 0.0
|
|
1436
1306
|
|
|
@@ -1459,7 +1329,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1459
1329
|
else:
|
|
1460
1330
|
self.logger.warning("Could not get liquidation price for stop-loss")
|
|
1461
1331
|
|
|
1462
|
-
# Create position record
|
|
1463
1332
|
self.current_position = BasisPosition(
|
|
1464
1333
|
coin=coin,
|
|
1465
1334
|
spot_asset_id=spot_asset_id,
|
|
@@ -1485,15 +1354,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1485
1354
|
# ------------------------------------------------------------------ #
|
|
1486
1355
|
|
|
1487
1356
|
async def _get_undeployed_capital(self) -> tuple[float, float]:
|
|
1488
|
-
"""
|
|
1489
|
-
Calculate undeployed capital that can be added to the position.
|
|
1490
|
-
|
|
1491
|
-
Returns:
|
|
1492
|
-
(perp_margin_available, spot_usdc_available)
|
|
1493
|
-
"""
|
|
1494
1357
|
address = self._get_strategy_wallet_address()
|
|
1495
1358
|
|
|
1496
|
-
# Get perp state
|
|
1497
1359
|
success, user_state = await self.hyperliquid_adapter.get_user_state(address)
|
|
1498
1360
|
if not success:
|
|
1499
1361
|
return 0.0, 0.0
|
|
@@ -1508,7 +1370,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1508
1370
|
|
|
1509
1371
|
withdrawable = float(withdrawable_val or 0.0)
|
|
1510
1372
|
|
|
1511
|
-
# Get spot USDC balance
|
|
1512
1373
|
success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
|
|
1513
1374
|
address
|
|
1514
1375
|
)
|
|
@@ -1527,13 +1388,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1527
1388
|
target_spot_usdc: float,
|
|
1528
1389
|
address: str,
|
|
1529
1390
|
) -> tuple[bool, str]:
|
|
1530
|
-
"""
|
|
1531
|
-
Rebalance Hyperliquid USDC between spot and perp to hit a target spot USDC balance.
|
|
1532
|
-
|
|
1533
|
-
Used before opening/scaling a basis position so we can:
|
|
1534
|
-
- fund the spot buy (spot USDC ~= target_spot_usdc)
|
|
1535
|
-
- keep the remainder in perp as margin (perp USDC ~= total - target_spot_usdc)
|
|
1536
|
-
"""
|
|
1537
1391
|
if target_spot_usdc <= 0:
|
|
1538
1392
|
return False, "Target spot USDC must be positive"
|
|
1539
1393
|
|
|
@@ -1592,32 +1446,17 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1592
1446
|
return True, f"Transferred ${amount:.2f} spot->perp"
|
|
1593
1447
|
|
|
1594
1448
|
async def _scale_up_position(self, additional_capital: float) -> StatusTuple:
|
|
1595
|
-
"""
|
|
1596
|
-
Add capital to existing position without breaking it.
|
|
1597
|
-
|
|
1598
|
-
Uses PairedFiller to atomically add to both spot and perp legs,
|
|
1599
|
-
maintaining delta neutrality.
|
|
1600
|
-
|
|
1601
|
-
Args:
|
|
1602
|
-
additional_capital: USD amount of new capital to deploy
|
|
1603
|
-
|
|
1604
|
-
Returns:
|
|
1605
|
-
StatusTuple (success, message)
|
|
1606
|
-
"""
|
|
1607
1449
|
if self.current_position is None:
|
|
1608
1450
|
return False, "No position to scale up"
|
|
1609
1451
|
|
|
1610
1452
|
pos = self.current_position
|
|
1611
1453
|
address = self._get_strategy_wallet_address()
|
|
1612
1454
|
|
|
1613
|
-
# Get current leverage from position
|
|
1614
1455
|
leverage = pos.leverage or 2
|
|
1615
1456
|
|
|
1616
|
-
# Calculate how much to add to each leg
|
|
1617
1457
|
# order_usd = capital * (L / (L + 1)) for leveraged position
|
|
1618
1458
|
order_usd = additional_capital * (leverage / (leverage + 1))
|
|
1619
1459
|
|
|
1620
|
-
# Get current price
|
|
1621
1460
|
success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
|
|
1622
1461
|
if not success:
|
|
1623
1462
|
return False, "Failed to get mid prices"
|
|
@@ -1626,14 +1465,12 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1626
1465
|
if price <= 0:
|
|
1627
1466
|
return False, f"Invalid price for {pos.coin}"
|
|
1628
1467
|
|
|
1629
|
-
# Check minimum notional ($10 USD per side)
|
|
1630
1468
|
if order_usd < MIN_NOTIONAL_USD:
|
|
1631
1469
|
return (
|
|
1632
1470
|
True,
|
|
1633
1471
|
f"Additional capital ${order_usd:.2f} below minimum notional ${MIN_NOTIONAL_USD}",
|
|
1634
1472
|
)
|
|
1635
1473
|
|
|
1636
|
-
# Calculate units to add
|
|
1637
1474
|
units_to_add = order_usd / price
|
|
1638
1475
|
|
|
1639
1476
|
# Round to valid decimals for the assets
|
|
@@ -1685,7 +1522,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1685
1522
|
spot_asset_id=pos.spot_asset_id,
|
|
1686
1523
|
perp_asset_id=pos.perp_asset_id,
|
|
1687
1524
|
total_units=units_to_add,
|
|
1688
|
-
direction="long_spot_short_perp",
|
|
1525
|
+
direction="long_spot_short_perp",
|
|
1689
1526
|
builder_fee=self.builder_fee,
|
|
1690
1527
|
)
|
|
1691
1528
|
except Exception as e:
|
|
@@ -1695,16 +1532,15 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1695
1532
|
if spot_filled <= 0 or perp_filled <= 0:
|
|
1696
1533
|
return False, f"Failed to add to position on {pos.coin}"
|
|
1697
1534
|
|
|
1698
|
-
# Update position tracking
|
|
1699
1535
|
self.current_position = BasisPosition(
|
|
1700
1536
|
coin=pos.coin,
|
|
1701
1537
|
spot_asset_id=pos.spot_asset_id,
|
|
1702
1538
|
perp_asset_id=pos.perp_asset_id,
|
|
1703
1539
|
spot_amount=pos.spot_amount + spot_filled,
|
|
1704
1540
|
perp_amount=pos.perp_amount + perp_filled,
|
|
1705
|
-
entry_price=price,
|
|
1541
|
+
entry_price=price,
|
|
1706
1542
|
leverage=leverage,
|
|
1707
|
-
entry_timestamp=pos.entry_timestamp,
|
|
1543
|
+
entry_timestamp=pos.entry_timestamp,
|
|
1708
1544
|
funding_collected=pos.funding_collected,
|
|
1709
1545
|
)
|
|
1710
1546
|
|
|
@@ -1719,15 +1555,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1719
1555
|
)
|
|
1720
1556
|
|
|
1721
1557
|
async def _monitor_position(self) -> StatusTuple:
|
|
1722
|
-
"""
|
|
1723
|
-
Monitor existing position for exit/rebalance conditions.
|
|
1724
|
-
|
|
1725
|
-
Checks:
|
|
1726
|
-
1. Whether rebalance is needed (funding, liquidity, etc.)
|
|
1727
|
-
2. Both legs are balanced (spot and perp amounts match)
|
|
1728
|
-
3. No significant idle capital (deploy if found)
|
|
1729
|
-
4. Stop-loss orders are in place and valid
|
|
1730
|
-
"""
|
|
1731
1558
|
if self.current_position is None:
|
|
1732
1559
|
return (True, "No position to monitor")
|
|
1733
1560
|
|
|
@@ -1736,12 +1563,10 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1736
1563
|
address = self._get_strategy_wallet_address()
|
|
1737
1564
|
actions_taken: list[str] = []
|
|
1738
1565
|
|
|
1739
|
-
# Get current state
|
|
1740
1566
|
success, state = await self.hyperliquid_adapter.get_user_state(address)
|
|
1741
1567
|
if not success:
|
|
1742
1568
|
return (False, f"Failed to fetch user state: {state}")
|
|
1743
1569
|
|
|
1744
|
-
# Calculate deposited amount from current on-exchange value
|
|
1745
1570
|
total_value, hl_value, _ = await self._get_total_portfolio_value()
|
|
1746
1571
|
|
|
1747
1572
|
# ------------------------------------------------------------------ #
|
|
@@ -1761,12 +1586,10 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1761
1586
|
return await self._find_and_open_position()
|
|
1762
1587
|
|
|
1763
1588
|
# ------------------------------------------------------------------ #
|
|
1764
|
-
# Check 1: Rebalance needed? #
|
|
1765
1589
|
# ------------------------------------------------------------------ #
|
|
1766
1590
|
needs_rebalance, reason = await self._needs_new_position(state, hl_value)
|
|
1767
1591
|
|
|
1768
1592
|
if needs_rebalance:
|
|
1769
|
-
# Check rotation cooldown
|
|
1770
1593
|
rotation_allowed, cooldown_reason = await self._is_rotation_allowed()
|
|
1771
1594
|
|
|
1772
1595
|
if not rotation_allowed:
|
|
@@ -1788,7 +1611,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1788
1611
|
return await self._find_and_open_position()
|
|
1789
1612
|
|
|
1790
1613
|
# ------------------------------------------------------------------ #
|
|
1791
|
-
# Check 2: Verify both legs are balanced #
|
|
1792
1614
|
# ------------------------------------------------------------------ #
|
|
1793
1615
|
leg_ok, leg_msg = await self._verify_leg_balance(state)
|
|
1794
1616
|
if not leg_ok:
|
|
@@ -1801,7 +1623,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1801
1623
|
actions_taken.append(f"Leg imbalance repair failed: {repair_msg}")
|
|
1802
1624
|
|
|
1803
1625
|
# ------------------------------------------------------------------ #
|
|
1804
|
-
# Check 3: Deploy any idle capital #
|
|
1805
1626
|
# ------------------------------------------------------------------ #
|
|
1806
1627
|
perp_margin, spot_usdc = await self._get_undeployed_capital()
|
|
1807
1628
|
total_idle = perp_margin + spot_usdc
|
|
@@ -1822,7 +1643,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1822
1643
|
actions_taken.append(f"Scale-up failed: {scale_msg}")
|
|
1823
1644
|
|
|
1824
1645
|
# ------------------------------------------------------------------ #
|
|
1825
|
-
# Check 4: Verify stop-loss orders #
|
|
1826
1646
|
# ------------------------------------------------------------------ #
|
|
1827
1647
|
sl_ok, sl_msg = await self._ensure_stop_loss_valid(state)
|
|
1828
1648
|
if not sl_ok:
|
|
@@ -1844,12 +1664,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1844
1664
|
)
|
|
1845
1665
|
|
|
1846
1666
|
async def _is_near_liquidation(self, state: dict[str, Any]) -> tuple[bool, str]:
|
|
1847
|
-
"""
|
|
1848
|
-
Check whether the perp leg is too close to liquidation.
|
|
1849
|
-
|
|
1850
|
-
For a short perp, liquidation is ABOVE entry. We measure progress from entry -> liquidation:
|
|
1851
|
-
frac = (mid - entry) / (liq - entry)
|
|
1852
|
-
"""
|
|
1853
1667
|
if self.current_position is None:
|
|
1854
1668
|
return False, "No position"
|
|
1855
1669
|
|
|
@@ -1908,19 +1722,12 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1908
1722
|
)
|
|
1909
1723
|
|
|
1910
1724
|
async def _verify_leg_balance(self, state: dict[str, Any]) -> tuple[bool, str]:
|
|
1911
|
-
"""
|
|
1912
|
-
Verify that spot and perp legs are balanced (delta neutral).
|
|
1913
|
-
|
|
1914
|
-
Returns:
|
|
1915
|
-
(is_balanced, message)
|
|
1916
|
-
"""
|
|
1917
1725
|
if self.current_position is None:
|
|
1918
1726
|
return True, "No position"
|
|
1919
1727
|
|
|
1920
1728
|
pos = self.current_position
|
|
1921
1729
|
coin = pos.coin
|
|
1922
1730
|
|
|
1923
|
-
# Get actual perp position size from state
|
|
1924
1731
|
perp_size = 0.0
|
|
1925
1732
|
for pos_wrapper in state.get("assetPositions", []):
|
|
1926
1733
|
position = pos_wrapper.get("position", {})
|
|
@@ -1928,7 +1735,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1928
1735
|
perp_size = abs(float(position.get("szi", 0)))
|
|
1929
1736
|
break
|
|
1930
1737
|
|
|
1931
|
-
# Get actual spot balance
|
|
1932
1738
|
address = self._get_strategy_wallet_address()
|
|
1933
1739
|
success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
|
|
1934
1740
|
address
|
|
@@ -1940,20 +1746,18 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1940
1746
|
spot_size = float(bal.get("total", 0))
|
|
1941
1747
|
break
|
|
1942
1748
|
|
|
1943
|
-
# Check balance - allow 2% tolerance
|
|
1944
1749
|
if spot_size <= 0 and perp_size <= 0:
|
|
1945
1750
|
return False, "Both legs are zero"
|
|
1946
1751
|
|
|
1947
1752
|
max_size = max(spot_size, perp_size)
|
|
1948
1753
|
if max_size > 0:
|
|
1949
1754
|
imbalance_pct = abs(spot_size - perp_size) / max_size
|
|
1950
|
-
if imbalance_pct > 0.02:
|
|
1755
|
+
if imbalance_pct > 0.02:
|
|
1951
1756
|
return (
|
|
1952
1757
|
False,
|
|
1953
1758
|
f"Imbalance: spot={spot_size:.6f}, perp={perp_size:.6f} ({imbalance_pct * 100:.1f}%)",
|
|
1954
1759
|
)
|
|
1955
1760
|
|
|
1956
|
-
# Update tracked position with actual values
|
|
1957
1761
|
self.current_position = BasisPosition(
|
|
1958
1762
|
coin=pos.coin,
|
|
1959
1763
|
spot_asset_id=pos.spot_asset_id,
|
|
@@ -1969,11 +1773,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1969
1773
|
return True, f"Balanced: spot={spot_size:.6f}, perp={perp_size:.6f}"
|
|
1970
1774
|
|
|
1971
1775
|
async def _repair_leg_imbalance(self, state: dict[str, Any]) -> tuple[bool, str]:
|
|
1972
|
-
"""
|
|
1973
|
-
Attempt to repair an imbalance between spot and perp legs.
|
|
1974
|
-
|
|
1975
|
-
If one leg is larger, adds to the smaller leg to match.
|
|
1976
|
-
"""
|
|
1977
1776
|
if self.current_position is None:
|
|
1978
1777
|
return True, "No position"
|
|
1979
1778
|
|
|
@@ -1981,7 +1780,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1981
1780
|
coin = pos.coin
|
|
1982
1781
|
address = self._get_strategy_wallet_address()
|
|
1983
1782
|
|
|
1984
|
-
# Get actual sizes
|
|
1985
1783
|
perp_size = 0.0
|
|
1986
1784
|
for pos_wrapper in state.get("assetPositions", []):
|
|
1987
1785
|
position = pos_wrapper.get("position", {})
|
|
@@ -2003,7 +1801,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2003
1801
|
if diff < 0.001:
|
|
2004
1802
|
return True, "Legs already balanced"
|
|
2005
1803
|
|
|
2006
|
-
# Get current price
|
|
2007
1804
|
success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
|
|
2008
1805
|
if not success:
|
|
2009
1806
|
return False, "Failed to get mid prices"
|
|
@@ -2012,7 +1809,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2012
1809
|
return False, f"Invalid price for {coin}"
|
|
2013
1810
|
|
|
2014
1811
|
diff_usd = diff * price
|
|
2015
|
-
if diff_usd < 10:
|
|
1812
|
+
if diff_usd < 10:
|
|
2016
1813
|
return True, f"Imbalance ${diff_usd:.2f} below minimum notional"
|
|
2017
1814
|
|
|
2018
1815
|
try:
|
|
@@ -2054,24 +1851,12 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2054
1851
|
return False, f"Repair failed: {e}"
|
|
2055
1852
|
|
|
2056
1853
|
async def _ensure_stop_loss_valid(self, state: dict[str, Any]) -> tuple[bool, str]:
|
|
2057
|
-
"""
|
|
2058
|
-
Ensure stop-loss orders are in place and valid for current position.
|
|
2059
|
-
|
|
2060
|
-
Checks:
|
|
2061
|
-
- Stop-loss exists for the perp leg
|
|
2062
|
-
- Trigger price is valid (below liquidation price)
|
|
2063
|
-
- Size matches position size
|
|
2064
|
-
|
|
2065
|
-
Returns:
|
|
2066
|
-
(success, message)
|
|
2067
|
-
"""
|
|
2068
1854
|
if self.current_position is None:
|
|
2069
1855
|
return True, "No position"
|
|
2070
1856
|
|
|
2071
1857
|
pos = self.current_position
|
|
2072
1858
|
coin = pos.coin
|
|
2073
1859
|
|
|
2074
|
-
# Get current perp position and liquidation price
|
|
2075
1860
|
perp_size = 0.0
|
|
2076
1861
|
liquidation_price = None
|
|
2077
1862
|
entry_price = pos.entry_price
|
|
@@ -2081,7 +1866,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2081
1866
|
if position.get("coin") == coin:
|
|
2082
1867
|
perp_size = abs(float(position.get("szi", 0)))
|
|
2083
1868
|
liquidation_price = float(position.get("liquidationPx", 0))
|
|
2084
|
-
# Update entry price from position if available
|
|
2085
1869
|
entry_px = position.get("entryPx")
|
|
2086
1870
|
if entry_px:
|
|
2087
1871
|
entry_price = float(entry_px)
|
|
@@ -2093,7 +1877,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2093
1877
|
if not liquidation_price or liquidation_price <= 0:
|
|
2094
1878
|
return False, "Could not determine liquidation price"
|
|
2095
1879
|
|
|
2096
|
-
# Get spot position size from LIVE balance (not stored position)
|
|
2097
1880
|
# to ensure stop-loss covers the actual spot holdings
|
|
2098
1881
|
spot_position = await self._get_spot_position()
|
|
2099
1882
|
if spot_position:
|
|
@@ -2101,7 +1884,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2101
1884
|
else:
|
|
2102
1885
|
spot_size = pos.spot_amount
|
|
2103
1886
|
|
|
2104
|
-
# Call existing method which checks and places/updates if needed
|
|
2105
1887
|
return await self._place_stop_loss_orders(
|
|
2106
1888
|
coin=coin,
|
|
2107
1889
|
perp_asset_id=pos.perp_asset_id,
|
|
@@ -2113,7 +1895,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2113
1895
|
)
|
|
2114
1896
|
|
|
2115
1897
|
async def _cancel_all_position_orders(self) -> None:
|
|
2116
|
-
"""Cancel all open orders (stop-loss, limit) for the current position."""
|
|
2117
1898
|
if self.current_position is None:
|
|
2118
1899
|
return
|
|
2119
1900
|
|
|
@@ -2123,7 +1904,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2123
1904
|
f"@{pos.spot_asset_id - 10000}" if pos.spot_asset_id >= 10000 else None
|
|
2124
1905
|
)
|
|
2125
1906
|
|
|
2126
|
-
# Get all open orders including triggers
|
|
2127
1907
|
success, open_orders = await self.hyperliquid_adapter.get_frontend_open_orders(
|
|
2128
1908
|
address
|
|
2129
1909
|
)
|
|
@@ -2154,7 +1934,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2154
1934
|
)
|
|
2155
1935
|
|
|
2156
1936
|
async def _close_position(self) -> StatusTuple:
|
|
2157
|
-
"""Close the current position."""
|
|
2158
1937
|
if self.current_position is None:
|
|
2159
1938
|
return (True, "No position to close")
|
|
2160
1939
|
|
|
@@ -2187,7 +1966,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2187
1966
|
spot_asset_id=pos.spot_asset_id,
|
|
2188
1967
|
perp_asset_id=pos.perp_asset_id,
|
|
2189
1968
|
total_units=close_units,
|
|
2190
|
-
direction="short_spot_long_perp",
|
|
1969
|
+
direction="short_spot_long_perp",
|
|
2191
1970
|
builder_fee=self.builder_fee,
|
|
2192
1971
|
)
|
|
2193
1972
|
|
|
@@ -2216,36 +1995,18 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2216
1995
|
deposited_amount: float,
|
|
2217
1996
|
best: dict[str, Any] | None = None,
|
|
2218
1997
|
) -> tuple[bool, str]:
|
|
2219
|
-
"""
|
|
2220
|
-
Check if current delta-neutral position needs rebalancing.
|
|
2221
|
-
|
|
2222
|
-
Implements the following health checks:
|
|
2223
|
-
1. Missing positions
|
|
2224
|
-
2. Asset mismatch (if best specified)
|
|
2225
|
-
3. Funding accumulation threshold
|
|
2226
|
-
4. Perp must be SHORT
|
|
2227
|
-
5. Position imbalance (±4% dust tolerance)
|
|
2228
|
-
6. Unused bankroll
|
|
2229
|
-
7. Stop-loss orders exist and are valid
|
|
2230
|
-
|
|
2231
|
-
Returns:
|
|
2232
|
-
(needs_rebalance, reason) - True if rebalance needed
|
|
2233
|
-
"""
|
|
2234
1998
|
perp_position = self._get_perp_position(state)
|
|
2235
1999
|
spot_position = await self._get_spot_position()
|
|
2236
2000
|
|
|
2237
|
-
# Check 1: Missing positions
|
|
2238
2001
|
if perp_position is None or spot_position is None:
|
|
2239
2002
|
return True, "Missing perp or spot position"
|
|
2240
2003
|
|
|
2241
|
-
# Check 2: Asset mismatch (if best specified)
|
|
2242
2004
|
if best:
|
|
2243
2005
|
if perp_position.get("asset_id") != best.get("perp_asset_id"):
|
|
2244
2006
|
return True, "Perp asset mismatch"
|
|
2245
2007
|
if spot_position.get("asset_id") != best.get("spot_asset_id"):
|
|
2246
2008
|
return True, "Spot asset mismatch"
|
|
2247
2009
|
|
|
2248
|
-
# Check 3: Funding accumulation threshold
|
|
2249
2010
|
funding_earned = self._get_funding_earned(state)
|
|
2250
2011
|
if funding_earned > deposited_amount * self.FUNDING_REBALANCE_THRESHOLD:
|
|
2251
2012
|
return True, f"Funding earned {funding_earned:.2f} exceeds threshold"
|
|
@@ -2255,7 +2016,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2255
2016
|
if perp_size >= 0:
|
|
2256
2017
|
return True, "Perp position is not short"
|
|
2257
2018
|
|
|
2258
|
-
# Check 5: Position imbalance (±4% dust tolerance)
|
|
2259
2019
|
spot_size = abs(float(spot_position.get("total", 0)))
|
|
2260
2020
|
perp_size_abs = abs(perp_size)
|
|
2261
2021
|
lower = spot_size * (1 - self.SPOT_POSITION_DUST_TOLERANCE)
|
|
@@ -2268,13 +2028,11 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2268
2028
|
# there's idle capital - that should be added to the existing position.
|
|
2269
2029
|
|
|
2270
2030
|
# Note: Stop-loss validation is handled separately in _monitor_position's
|
|
2271
|
-
# Check 4 (_ensure_stop_loss_valid) which will place/update orders as needed
|
|
2272
2031
|
# without triggering a full rebalance.
|
|
2273
2032
|
|
|
2274
2033
|
return False, "Position healthy"
|
|
2275
2034
|
|
|
2276
2035
|
def _get_perp_position(self, state: dict[str, Any]) -> dict[str, Any] | None:
|
|
2277
|
-
"""Extract perp position matching current position from user state."""
|
|
2278
2036
|
if self.current_position is None:
|
|
2279
2037
|
return None
|
|
2280
2038
|
|
|
@@ -2289,7 +2047,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2289
2047
|
return None
|
|
2290
2048
|
|
|
2291
2049
|
async def _get_spot_position(self) -> dict[str, Any] | None:
|
|
2292
|
-
"""Get spot position from spot user state."""
|
|
2293
2050
|
if self.current_position is None:
|
|
2294
2051
|
return None
|
|
2295
2052
|
|
|
@@ -2310,7 +2067,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2310
2067
|
return None
|
|
2311
2068
|
|
|
2312
2069
|
def _get_funding_earned(self, state: dict[str, Any]) -> float:
|
|
2313
|
-
"""Extract cumulative funding earned from user state."""
|
|
2314
2070
|
if self.current_position is None:
|
|
2315
2071
|
return 0.0
|
|
2316
2072
|
|
|
@@ -2332,18 +2088,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2332
2088
|
spot_asset_id: int | None = None,
|
|
2333
2089
|
spot_position_size: float | None = None,
|
|
2334
2090
|
) -> tuple[bool, str]:
|
|
2335
|
-
"""
|
|
2336
|
-
Place stop-loss orders for both perp and spot legs.
|
|
2337
|
-
|
|
2338
|
-
For basis trading:
|
|
2339
|
-
- Perp leg: Stop-market trigger order (buy to close short when price rises)
|
|
2340
|
-
- Spot leg: Limit sell order (sell spot at stop-loss price)
|
|
2341
|
-
|
|
2342
|
-
Both orders together maintain delta neutrality when the stop-loss is hit.
|
|
2343
|
-
"""
|
|
2344
2091
|
address = self._get_strategy_wallet_address()
|
|
2345
2092
|
|
|
2346
|
-
# Get spot info from current position if not provided
|
|
2347
2093
|
if spot_asset_id is None or spot_position_size is None:
|
|
2348
2094
|
if self.current_position:
|
|
2349
2095
|
spot_asset_id = self.current_position.spot_asset_id
|
|
@@ -2352,7 +2098,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2352
2098
|
spot_asset_id = None
|
|
2353
2099
|
spot_position_size = 0.0
|
|
2354
2100
|
|
|
2355
|
-
# Calculate stop-loss trigger price (90% of distance to liquidation)
|
|
2356
2101
|
# For short perp, liquidation is ABOVE entry price
|
|
2357
2102
|
stop_loss_price = (
|
|
2358
2103
|
entry_price
|
|
@@ -2361,7 +2106,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2361
2106
|
# Round to 5 significant figures to avoid SDK float_to_wire precision errors
|
|
2362
2107
|
stop_loss_price = float(f"{stop_loss_price:.5g}")
|
|
2363
2108
|
|
|
2364
|
-
# Get all open orders (frontend_open_orders includes trigger orders)
|
|
2365
2109
|
success, open_orders = await self.hyperliquid_adapter.get_frontend_open_orders(
|
|
2366
2110
|
address
|
|
2367
2111
|
)
|
|
@@ -2384,9 +2128,8 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2384
2128
|
order_id = order.get("oid")
|
|
2385
2129
|
is_trigger = order.get("isTrigger", False)
|
|
2386
2130
|
order_type = str(order.get("orderType", "")).lower()
|
|
2387
|
-
is_sell = order.get("side", "").upper() == "A"
|
|
2131
|
+
is_sell = order.get("side", "").upper() == "A"
|
|
2388
2132
|
|
|
2389
|
-
# Check PERP trigger orders (stop-loss)
|
|
2390
2133
|
if order_coin == coin:
|
|
2391
2134
|
is_trigger_order = (
|
|
2392
2135
|
is_trigger or "stop" in order_type or "trigger" in order_type
|
|
@@ -2414,13 +2157,11 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2414
2157
|
(perp_asset_id, order_id, "perp stop-loss")
|
|
2415
2158
|
)
|
|
2416
2159
|
|
|
2417
|
-
# Check SPOT limit sell orders
|
|
2418
2160
|
if spot_coin and order_coin == spot_coin and is_sell:
|
|
2419
2161
|
# This is a spot sell order (could be our stop-loss limit)
|
|
2420
2162
|
existing_price = float(order.get("limitPx", 0))
|
|
2421
2163
|
existing_size = float(order.get("sz", 0))
|
|
2422
2164
|
|
|
2423
|
-
# Check if it's around our stop-loss price (within 5%)
|
|
2424
2165
|
price_match = (
|
|
2425
2166
|
abs(existing_price - stop_loss_price) / stop_loss_price < 0.05
|
|
2426
2167
|
)
|
|
@@ -2455,7 +2196,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2455
2196
|
if not has_valid_perp_stop:
|
|
2456
2197
|
success, result = await self.hyperliquid_adapter.place_stop_loss(
|
|
2457
2198
|
asset_id=perp_asset_id,
|
|
2458
|
-
is_buy=True,
|
|
2199
|
+
is_buy=True,
|
|
2459
2200
|
trigger_price=stop_loss_price,
|
|
2460
2201
|
size=position_size,
|
|
2461
2202
|
address=address,
|
|
@@ -2471,18 +2212,17 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2471
2212
|
and spot_position_size > 0
|
|
2472
2213
|
and not has_valid_spot_limit
|
|
2473
2214
|
):
|
|
2474
|
-
# Get valid order size for spot
|
|
2475
2215
|
spot_sell_size = self.hyperliquid_adapter.get_valid_order_size(
|
|
2476
2216
|
spot_asset_id, spot_position_size
|
|
2477
2217
|
)
|
|
2478
2218
|
if spot_sell_size > 0:
|
|
2479
2219
|
success, result = await self.hyperliquid_adapter.place_limit_order(
|
|
2480
2220
|
asset_id=spot_asset_id,
|
|
2481
|
-
is_buy=False,
|
|
2221
|
+
is_buy=False,
|
|
2482
2222
|
price=stop_loss_price,
|
|
2483
2223
|
size=spot_sell_size,
|
|
2484
2224
|
address=address,
|
|
2485
|
-
reduce_only=False,
|
|
2225
|
+
reduce_only=False,
|
|
2486
2226
|
)
|
|
2487
2227
|
if not success:
|
|
2488
2228
|
self.logger.warning(f"Failed to place spot limit sell: {result}")
|
|
@@ -2499,7 +2239,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2499
2239
|
# ------------------------------------------------------------------ #
|
|
2500
2240
|
|
|
2501
2241
|
async def _get_last_rotation_time(self) -> datetime | None:
|
|
2502
|
-
"""Get timestamp of last position rotation from ledger."""
|
|
2503
2242
|
wallet_address = self._get_strategy_wallet_address()
|
|
2504
2243
|
|
|
2505
2244
|
try:
|
|
@@ -2528,7 +2267,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2528
2267
|
return None
|
|
2529
2268
|
|
|
2530
2269
|
async def _is_rotation_allowed(self) -> tuple[bool, str]:
|
|
2531
|
-
"""Check if rotation cooldown has passed."""
|
|
2532
2270
|
if self.current_position is None:
|
|
2533
2271
|
return True, "No existing position"
|
|
2534
2272
|
|
|
@@ -2555,29 +2293,20 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2555
2293
|
# ------------------------------------------------------------------ #
|
|
2556
2294
|
|
|
2557
2295
|
async def _get_total_portfolio_value(self) -> tuple[float, float, float]:
|
|
2558
|
-
"""
|
|
2559
|
-
Get total portfolio value including Hyperliquid and vault balances.
|
|
2560
|
-
|
|
2561
|
-
Returns:
|
|
2562
|
-
(total_value, hyperliquid_value, vault_wallet_value)
|
|
2563
|
-
"""
|
|
2564
2296
|
address = self._get_strategy_wallet_address()
|
|
2565
2297
|
|
|
2566
|
-
# Get Hyperliquid account value
|
|
2567
2298
|
hl_value = 0.0
|
|
2568
2299
|
success, user_state = await self.hyperliquid_adapter.get_user_state(address)
|
|
2569
2300
|
if success:
|
|
2570
2301
|
margin_summary = user_state.get("marginSummary", {})
|
|
2571
2302
|
hl_value = float(margin_summary.get("accountValue", 0))
|
|
2572
2303
|
|
|
2573
|
-
# Add spot value (all spot holdings, not just USDC)
|
|
2574
2304
|
(
|
|
2575
2305
|
success_spot,
|
|
2576
2306
|
spot_state,
|
|
2577
2307
|
) = await self.hyperliquid_adapter.get_spot_user_state(address)
|
|
2578
2308
|
if success_spot:
|
|
2579
2309
|
spot_balances = spot_state.get("balances", [])
|
|
2580
|
-
# Get mid prices for non-USDC assets
|
|
2581
2310
|
mid_prices: dict[str, float] = {}
|
|
2582
2311
|
if any(bal.get("coin") != "USDC" for bal in spot_balances):
|
|
2583
2312
|
(
|
|
@@ -2606,7 +2335,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2606
2335
|
f"No mid price found for spot {coin}, skipping"
|
|
2607
2336
|
)
|
|
2608
2337
|
|
|
2609
|
-
# Get strategy wallet USDC balance (on Arbitrum)
|
|
2610
2338
|
strategy_wallet_value = 0.0
|
|
2611
2339
|
try:
|
|
2612
2340
|
strategy_address = self._get_strategy_wallet_address()
|
|
@@ -2615,7 +2343,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2615
2343
|
wallet_address=strategy_address,
|
|
2616
2344
|
)
|
|
2617
2345
|
if success and balance:
|
|
2618
|
-
strategy_wallet_value = float(balance) / 1e6
|
|
2346
|
+
strategy_wallet_value = float(balance) / 1e6
|
|
2619
2347
|
except Exception as e:
|
|
2620
2348
|
self.logger.debug(f"Could not fetch strategy wallet balance: {e}")
|
|
2621
2349
|
|
|
@@ -2632,7 +2360,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2632
2360
|
idx_to_token: dict[int, str],
|
|
2633
2361
|
perps_set: set[str],
|
|
2634
2362
|
) -> list[tuple[str, str, int]]:
|
|
2635
|
-
"""Find spot-perp pairs that can form basis trades."""
|
|
2636
2363
|
candidates: list[tuple[str, str, int]] = []
|
|
2637
2364
|
|
|
2638
2365
|
for pe in spot_pairs:
|
|
@@ -2650,7 +2377,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2650
2377
|
spot_pair_name = f"{base}/{quote}"
|
|
2651
2378
|
spot_asset_id = pe["index"] + 10000
|
|
2652
2379
|
|
|
2653
|
-
# Handle USDT prefixed tokens (UPUMP -> PUMP)
|
|
2654
2380
|
base_norm = (
|
|
2655
2381
|
base[1:] if (base.startswith("U") and base[1:] in perps_set) else base
|
|
2656
2382
|
)
|
|
@@ -2672,7 +2398,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2672
2398
|
perp_coin_to_asset_id: dict[str, int],
|
|
2673
2399
|
depth_params: dict[str, Any] | None = None,
|
|
2674
2400
|
) -> list[BasisCandidate]:
|
|
2675
|
-
"""Filter candidates by liquidity and venue depth, returning structured data."""
|
|
2676
2401
|
liquid: list[BasisCandidate] = []
|
|
2677
2402
|
|
|
2678
2403
|
if deposit_usdc <= 0:
|
|
@@ -2706,7 +2431,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2706
2431
|
if order_usd <= 0:
|
|
2707
2432
|
continue
|
|
2708
2433
|
|
|
2709
|
-
# Get spot order book
|
|
2710
2434
|
try:
|
|
2711
2435
|
book_snapshot = await self._l2_book_spot(
|
|
2712
2436
|
spot_asset_id,
|
|
@@ -2764,29 +2488,14 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2764
2488
|
# Chunked Data Fetching #
|
|
2765
2489
|
# ------------------------------------------------------------------ #
|
|
2766
2490
|
|
|
2767
|
-
HOURS_PER_CHUNK = 500
|
|
2768
|
-
CHUNK_DELAY_SECONDS = 0.2
|
|
2491
|
+
HOURS_PER_CHUNK = 500
|
|
2492
|
+
CHUNK_DELAY_SECONDS = 0.2
|
|
2769
2493
|
|
|
2770
2494
|
def _hour_chunks(
|
|
2771
2495
|
self, start_ms: int, end_ms: int, step_hours: int = 500
|
|
2772
2496
|
) -> list[tuple[int, int]]:
|
|
2773
|
-
"""
|
|
2774
|
-
Generate time chunks for API calls.
|
|
2775
|
-
|
|
2776
|
-
Each chunk is (start_ms, end_ms) tuple representing a time window
|
|
2777
|
-
of up to `step_hours` hours. This allows fetching >500 data points
|
|
2778
|
-
by making multiple API calls.
|
|
2779
|
-
|
|
2780
|
-
Args:
|
|
2781
|
-
start_ms: Start time in milliseconds
|
|
2782
|
-
end_ms: End time in milliseconds
|
|
2783
|
-
step_hours: Hours per chunk (default 500, Hyperliquid API limit)
|
|
2784
|
-
|
|
2785
|
-
Returns:
|
|
2786
|
-
List of (chunk_start_ms, chunk_end_ms) tuples
|
|
2787
|
-
"""
|
|
2788
2497
|
chunks = []
|
|
2789
|
-
step_ms = step_hours * 3600 * 1000
|
|
2498
|
+
step_ms = step_hours * 3600 * 1000
|
|
2790
2499
|
t0 = start_ms
|
|
2791
2500
|
|
|
2792
2501
|
while t0 < end_ms:
|
|
@@ -2802,30 +2511,14 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2802
2511
|
start_ms: int,
|
|
2803
2512
|
end_ms: int | None = None,
|
|
2804
2513
|
) -> tuple[bool, list[dict[str, Any]]]:
|
|
2805
|
-
"""
|
|
2806
|
-
Fetch funding history with automatic chunking for long time ranges.
|
|
2807
|
-
|
|
2808
|
-
Hyperliquid API returns max ~500 data points per call. This method
|
|
2809
|
-
automatically splits long requests into multiple chunks and merges
|
|
2810
|
-
the results.
|
|
2811
|
-
|
|
2812
|
-
Args:
|
|
2813
|
-
coin: Coin symbol (e.g., "ETH", "BTC")
|
|
2814
|
-
start_ms: Start time in milliseconds
|
|
2815
|
-
end_ms: End time in milliseconds (defaults to now)
|
|
2816
|
-
|
|
2817
|
-
Returns:
|
|
2818
|
-
(success, combined_funding_data)
|
|
2819
|
-
"""
|
|
2820
2514
|
if end_ms is None:
|
|
2821
2515
|
end_ms = int(time.time() * 1000)
|
|
2822
2516
|
|
|
2823
2517
|
chunks = self._hour_chunks(start_ms, end_ms, self.HOURS_PER_CHUNK)
|
|
2824
2518
|
all_funding: list[dict[str, Any]] = []
|
|
2825
|
-
seen_times: set[int] = set()
|
|
2519
|
+
seen_times: set[int] = set()
|
|
2826
2520
|
|
|
2827
2521
|
for i, (chunk_start, chunk_end) in enumerate(chunks):
|
|
2828
|
-
# Add delay between chunks to avoid rate limiting
|
|
2829
2522
|
if i > 0:
|
|
2830
2523
|
await asyncio.sleep(self.CHUNK_DELAY_SECONDS)
|
|
2831
2524
|
|
|
@@ -2866,27 +2559,14 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2866
2559
|
start_ms: int,
|
|
2867
2560
|
end_ms: int | None = None,
|
|
2868
2561
|
) -> tuple[bool, list[dict[str, Any]]]:
|
|
2869
|
-
"""
|
|
2870
|
-
Fetch candle data with automatic chunking for long time ranges.
|
|
2871
|
-
|
|
2872
|
-
Args:
|
|
2873
|
-
coin: Coin symbol (e.g., "ETH", "BTC")
|
|
2874
|
-
interval: Candle interval (e.g., "1h")
|
|
2875
|
-
start_ms: Start time in milliseconds
|
|
2876
|
-
end_ms: End time in milliseconds (defaults to now)
|
|
2877
|
-
|
|
2878
|
-
Returns:
|
|
2879
|
-
(success, combined_candle_data)
|
|
2880
|
-
"""
|
|
2881
2562
|
if end_ms is None:
|
|
2882
2563
|
end_ms = int(time.time() * 1000)
|
|
2883
2564
|
|
|
2884
2565
|
chunks = self._hour_chunks(start_ms, end_ms, self.HOURS_PER_CHUNK)
|
|
2885
2566
|
all_candles: list[dict[str, Any]] = []
|
|
2886
|
-
seen_times: set[int] = set()
|
|
2567
|
+
seen_times: set[int] = set()
|
|
2887
2568
|
|
|
2888
2569
|
for i, (chunk_start, chunk_end) in enumerate(chunks):
|
|
2889
|
-
# Add delay between chunks to avoid rate limiting
|
|
2890
2570
|
if i > 0:
|
|
2891
2571
|
await asyncio.sleep(self.CHUNK_DELAY_SECONDS)
|
|
2892
2572
|
|
|
@@ -2931,7 +2611,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2931
2611
|
*,
|
|
2932
2612
|
fallback_mid: float | None = None,
|
|
2933
2613
|
) -> dict[str, Any]:
|
|
2934
|
-
"""Normalize Hyperliquid L2 into bids/asks lists with floats."""
|
|
2935
2614
|
return hl_normalize_l2_book(raw, fallback_mid=fallback_mid)
|
|
2936
2615
|
|
|
2937
2616
|
async def _l2_book_spot(
|
|
@@ -2941,7 +2620,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2941
2620
|
fallback_mid: float | None = None,
|
|
2942
2621
|
spot_symbol: str | None = None,
|
|
2943
2622
|
) -> dict[str, Any]:
|
|
2944
|
-
"""Fetch and normalize Level-2 order book snapshot for a spot asset."""
|
|
2945
2623
|
last_exc: Exception | None = None
|
|
2946
2624
|
|
|
2947
2625
|
try:
|
|
@@ -2997,7 +2675,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2997
2675
|
max_bps: int = 100,
|
|
2998
2676
|
gamma: int = 20,
|
|
2999
2677
|
) -> int:
|
|
3000
|
-
"""Widen the depth band slowly with order size."""
|
|
3001
2678
|
if order_usd <= 0:
|
|
3002
2679
|
return base_bps
|
|
3003
2680
|
|
|
@@ -3017,12 +2694,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3017
2694
|
fallback_mid: float | None = None,
|
|
3018
2695
|
spot_symbol: str | None = None,
|
|
3019
2696
|
) -> dict[str, Any]:
|
|
3020
|
-
"""
|
|
3021
|
-
Heuristic spot book depth gate using USD notionals.
|
|
3022
|
-
|
|
3023
|
-
Returns diagnostics including available depth, thresholds, and pass/fail flags.
|
|
3024
|
-
"""
|
|
3025
|
-
|
|
3026
2697
|
config: dict[str, Any] = {
|
|
3027
2698
|
"base_band_bps": 50,
|
|
3028
2699
|
"max_band_bps": 100,
|
|
@@ -3148,8 +2819,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3148
2819
|
day_ntl_usd: float | None = None,
|
|
3149
2820
|
spot_symbol: str | None = None,
|
|
3150
2821
|
) -> tuple[float, float, dict[str, float], dict[str, dict[str, Any]]]:
|
|
3151
|
-
"""Estimate entry/exit execution costs for a full cycle on both legs."""
|
|
3152
|
-
|
|
3153
2822
|
cfg_fees = {"spot_bps": 9.0, "perp_bps": 6.0}
|
|
3154
2823
|
if fee_model:
|
|
3155
2824
|
cfg_fees.update(fee_model)
|
|
@@ -3210,7 +2879,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3210
2879
|
return entry_cost, exit_cost, breakdown, {"buy": buy_chk, "sell": sell_chk}
|
|
3211
2880
|
|
|
3212
2881
|
async def _get_margin_table_tiers(self, table_id: int) -> list[dict[str, float]]:
|
|
3213
|
-
"""Fetch and cache margin table tiers with maintenance rates and deductions."""
|
|
3214
2882
|
if table_id in self._margin_table_cache:
|
|
3215
2883
|
return [dict(t) for t in self._margin_table_cache[table_id]]
|
|
3216
2884
|
|
|
@@ -3275,7 +2943,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3275
2943
|
notional_usd: float,
|
|
3276
2944
|
fallback_max_leverage: int,
|
|
3277
2945
|
) -> float:
|
|
3278
|
-
"""Return maintenance margin fraction for a given notional, honoring tiered tables."""
|
|
3279
2946
|
fallback_mmr = self.maintenance_rate_from_max_leverage(
|
|
3280
2947
|
max(1, int(fallback_max_leverage))
|
|
3281
2948
|
)
|
|
@@ -3316,7 +2983,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3316
2983
|
maintenance_fn,
|
|
3317
2984
|
base_notional: float,
|
|
3318
2985
|
) -> int:
|
|
3319
|
-
"""Return the forward hours until the stop barrier is hit or data is exhausted."""
|
|
3320
2986
|
n = min(len(closes), len(highs), len(hourly_funding)) - 1
|
|
3321
2987
|
if start_idx >= n:
|
|
3322
2988
|
return 0
|
|
@@ -3370,7 +3036,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3370
3036
|
fallback_max_leverage: int,
|
|
3371
3037
|
cooloff_hours: int = 0,
|
|
3372
3038
|
) -> dict[str, float]:
|
|
3373
|
-
"""Simulate repeated entries/exits under a stop barrier and accumulate PnL."""
|
|
3374
3039
|
n = min(len(funding), len(closes), len(highs)) - 1
|
|
3375
3040
|
if n <= 0:
|
|
3376
3041
|
return {
|
|
@@ -3446,7 +3111,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3446
3111
|
|
|
3447
3112
|
@staticmethod
|
|
3448
3113
|
def _percentile(sorted_values: list[float], pct: float) -> float:
|
|
3449
|
-
"""Inclusive percentile on a pre-sorted list."""
|
|
3450
3114
|
return analytics_percentile(sorted_values, pct)
|
|
3451
3115
|
|
|
3452
3116
|
def _block_bootstrap_paths(
|
|
@@ -3459,7 +3123,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3459
3123
|
sims: int,
|
|
3460
3124
|
rng: random.Random,
|
|
3461
3125
|
) -> list[tuple[list[float], list[float], list[float]]]:
|
|
3462
|
-
"""Return block-bootstrap resampled series for funding/close/high paths."""
|
|
3463
3126
|
paths = analytics_block_bootstrap_paths(
|
|
3464
3127
|
funding,
|
|
3465
3128
|
closes,
|
|
@@ -3490,7 +3153,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3490
3153
|
block_hours: int,
|
|
3491
3154
|
seed: int | None,
|
|
3492
3155
|
) -> dict[str, Any] | None:
|
|
3493
|
-
"""Run block-bootstrap replays and summarize churn metrics."""
|
|
3494
3156
|
if sims <= 0 or deposit_usdc <= 0:
|
|
3495
3157
|
return None
|
|
3496
3158
|
|
|
@@ -3597,8 +3259,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3597
3259
|
fee_eps: float,
|
|
3598
3260
|
require_full_window: bool = True,
|
|
3599
3261
|
) -> float:
|
|
3600
|
-
"""Worst-case buffer requirement accounting for tiered maintenance margin."""
|
|
3601
|
-
|
|
3602
3262
|
fallback_mmr = self.maintenance_rate_from_max_leverage(
|
|
3603
3263
|
max(1, int(fallback_max_leverage))
|
|
3604
3264
|
)
|
|
@@ -3659,7 +3319,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3659
3319
|
def round_size_for_hypecore_asset(
|
|
3660
3320
|
self, asset_id: int, size: float | Decimal, *, ensure_min_step: bool = False
|
|
3661
3321
|
) -> float:
|
|
3662
|
-
"""Floor to step using Decimal to avoid float issues."""
|
|
3663
3322
|
try:
|
|
3664
3323
|
mapping = self.hyperliquid_adapter.asset_to_sz_decimals
|
|
3665
3324
|
except Exception as exc: # noqa: BLE001
|
|
@@ -3688,11 +3347,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3688
3347
|
spot_asset_id: int,
|
|
3689
3348
|
perp_asset_id: int | None,
|
|
3690
3349
|
) -> float:
|
|
3691
|
-
"""
|
|
3692
|
-
Minimum USDC deposit to place at least one lot on both legs at leverage L.
|
|
3693
|
-
|
|
3694
|
-
D_min(L) = N * (1 + 1/L), with N = unit_step * mark_px.
|
|
3695
|
-
"""
|
|
3696
3350
|
L = max(1, int(leverage))
|
|
3697
3351
|
unit_step = self._common_unit_step(spot_asset_id, perp_asset_id)
|
|
3698
3352
|
mark = _d(mark_price)
|
|
@@ -3708,11 +3362,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3708
3362
|
day_ntl_usd: float | None,
|
|
3709
3363
|
params: dict[str, Any] | None,
|
|
3710
3364
|
) -> float:
|
|
3711
|
-
"""
|
|
3712
|
-
Conservative upper bound for order size that could ever pass depth checks.
|
|
3713
|
-
|
|
3714
|
-
Uses depth at max band and turnover cap (if provided).
|
|
3715
|
-
"""
|
|
3716
3365
|
config: dict[str, Any] = {
|
|
3717
3366
|
"max_band_bps": 100,
|
|
3718
3367
|
"max_fill_ratio": 0.10,
|
|
@@ -3769,12 +3418,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3769
3418
|
params: dict[str, Any] | None = None,
|
|
3770
3419
|
refine_iters: int = 12,
|
|
3771
3420
|
) -> dict[str, Any]:
|
|
3772
|
-
"""
|
|
3773
|
-
Compute the maximum order_usd that passes spot depth checks on both sides.
|
|
3774
|
-
|
|
3775
|
-
This is used for batch precompute so workers can quickly filter candidates
|
|
3776
|
-
by a user's required order size.
|
|
3777
|
-
"""
|
|
3778
3421
|
upper_buy = self._depth_upper_bound_usd(
|
|
3779
3422
|
book=book, side="buy", day_ntl_usd=day_ntl_usd, params=params
|
|
3780
3423
|
)
|
|
@@ -3933,8 +3576,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
3933
3576
|
bootstrap_block_hours: int = DEFAULT_BOOTSTRAP_BLOCK_HOURS,
|
|
3934
3577
|
bootstrap_seed: int | None = None,
|
|
3935
3578
|
) -> list[dict[str, Any]]:
|
|
3936
|
-
"""Rank spot/perp pairs by simulated net APY under stop-driven churn."""
|
|
3937
|
-
|
|
3938
3579
|
if deposit_usdc <= 0:
|
|
3939
3580
|
return []
|
|
3940
3581
|
|
|
@@ -4159,22 +3800,18 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
4159
3800
|
# ------------------------------------------------------------------ #
|
|
4160
3801
|
|
|
4161
3802
|
def _z_from_conf(self, confidence: float) -> float:
|
|
4162
|
-
"""Get z-score for given confidence level."""
|
|
4163
3803
|
return analytics_z_from_conf(confidence)
|
|
4164
3804
|
|
|
4165
3805
|
def _rolling_min_sum(self, arr: list[float], window: int) -> float:
|
|
4166
|
-
"""Calculate minimum rolling sum over window."""
|
|
4167
3806
|
return analytics_rolling_min_sum(arr, window)
|
|
4168
3807
|
|
|
4169
3808
|
@staticmethod
|
|
4170
3809
|
def maintenance_rate_from_max_leverage(max_lev: int) -> float:
|
|
4171
|
-
"""Estimate maintenance margin rate from max leverage."""
|
|
4172
3810
|
if max_lev <= 0:
|
|
4173
3811
|
return 0.5
|
|
4174
3812
|
return 0.5 / max_lev
|
|
4175
3813
|
|
|
4176
3814
|
def _get_strategy_wallet_address(self) -> str:
|
|
4177
|
-
"""Get strategy wallet address from config."""
|
|
4178
3815
|
strategy_wallet = self.config.get("strategy_wallet")
|
|
4179
3816
|
if not strategy_wallet or not isinstance(strategy_wallet, dict):
|
|
4180
3817
|
raise ValueError("strategy_wallet not configured")
|
|
@@ -4184,7 +3821,6 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
4184
3821
|
return str(address)
|
|
4185
3822
|
|
|
4186
3823
|
def _get_main_wallet_address(self) -> str:
|
|
4187
|
-
"""Get main wallet address from config."""
|
|
4188
3824
|
main_wallet = self.config.get("main_wallet")
|
|
4189
3825
|
if not main_wallet or not isinstance(main_wallet, dict):
|
|
4190
3826
|
raise ValueError("main_wallet not configured")
|