wayfinder-paths 0.1.7__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/CONFIG_GUIDE.md +399 -0
- wayfinder_paths/__init__.py +22 -0
- wayfinder_paths/abis/generic/erc20.json +383 -0
- wayfinder_paths/adapters/__init__.py +0 -0
- wayfinder_paths/adapters/balance_adapter/README.md +94 -0
- wayfinder_paths/adapters/balance_adapter/adapter.py +238 -0
- wayfinder_paths/adapters/balance_adapter/examples.json +6 -0
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +59 -0
- wayfinder_paths/adapters/brap_adapter/README.md +249 -0
- wayfinder_paths/adapters/brap_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/brap_adapter/adapter.py +726 -0
- wayfinder_paths/adapters/brap_adapter/examples.json +175 -0
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +11 -0
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +286 -0
- wayfinder_paths/adapters/hyperlend_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +305 -0
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +10 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +274 -0
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
- wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
- wayfinder_paths/adapters/ledger_adapter/README.md +145 -0
- wayfinder_paths/adapters/ledger_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/ledger_adapter/adapter.py +289 -0
- wayfinder_paths/adapters/ledger_adapter/examples.json +137 -0
- wayfinder_paths/adapters/ledger_adapter/manifest.yaml +11 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +205 -0
- wayfinder_paths/adapters/pool_adapter/README.md +206 -0
- wayfinder_paths/adapters/pool_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/pool_adapter/adapter.py +282 -0
- wayfinder_paths/adapters/pool_adapter/examples.json +143 -0
- wayfinder_paths/adapters/pool_adapter/manifest.yaml +10 -0
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +220 -0
- wayfinder_paths/adapters/token_adapter/README.md +101 -0
- wayfinder_paths/adapters/token_adapter/__init__.py +3 -0
- wayfinder_paths/adapters/token_adapter/adapter.py +96 -0
- wayfinder_paths/adapters/token_adapter/examples.json +26 -0
- wayfinder_paths/adapters/token_adapter/manifest.yaml +6 -0
- wayfinder_paths/adapters/token_adapter/test_adapter.py +125 -0
- wayfinder_paths/config.example.json +22 -0
- wayfinder_paths/conftest.py +31 -0
- wayfinder_paths/core/__init__.py +18 -0
- wayfinder_paths/core/adapters/BaseAdapter.py +65 -0
- wayfinder_paths/core/adapters/__init__.py +5 -0
- wayfinder_paths/core/adapters/base.py +5 -0
- wayfinder_paths/core/adapters/models.py +46 -0
- wayfinder_paths/core/analytics/__init__.py +11 -0
- wayfinder_paths/core/analytics/bootstrap.py +57 -0
- wayfinder_paths/core/analytics/stats.py +48 -0
- wayfinder_paths/core/analytics/test_analytics.py +170 -0
- wayfinder_paths/core/clients/AuthClient.py +83 -0
- wayfinder_paths/core/clients/BRAPClient.py +109 -0
- wayfinder_paths/core/clients/ClientManager.py +210 -0
- wayfinder_paths/core/clients/HyperlendClient.py +192 -0
- wayfinder_paths/core/clients/LedgerClient.py +443 -0
- wayfinder_paths/core/clients/PoolClient.py +128 -0
- wayfinder_paths/core/clients/SimulationClient.py +192 -0
- wayfinder_paths/core/clients/TokenClient.py +89 -0
- wayfinder_paths/core/clients/TransactionClient.py +63 -0
- wayfinder_paths/core/clients/WalletClient.py +94 -0
- wayfinder_paths/core/clients/WayfinderClient.py +269 -0
- wayfinder_paths/core/clients/__init__.py +48 -0
- wayfinder_paths/core/clients/protocols.py +392 -0
- wayfinder_paths/core/clients/sdk_example.py +110 -0
- wayfinder_paths/core/config.py +458 -0
- wayfinder_paths/core/constants/__init__.py +26 -0
- wayfinder_paths/core/constants/base.py +42 -0
- wayfinder_paths/core/constants/erc20_abi.py +118 -0
- wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
- wayfinder_paths/core/engine/StrategyJob.py +188 -0
- wayfinder_paths/core/engine/__init__.py +5 -0
- wayfinder_paths/core/engine/manifest.py +97 -0
- wayfinder_paths/core/services/__init__.py +0 -0
- wayfinder_paths/core/services/base.py +179 -0
- wayfinder_paths/core/services/local_evm_txn.py +430 -0
- wayfinder_paths/core/services/local_token_txn.py +231 -0
- wayfinder_paths/core/services/web3_service.py +45 -0
- wayfinder_paths/core/settings.py +61 -0
- wayfinder_paths/core/strategies/Strategy.py +280 -0
- wayfinder_paths/core/strategies/__init__.py +5 -0
- wayfinder_paths/core/strategies/base.py +7 -0
- wayfinder_paths/core/strategies/descriptors.py +81 -0
- wayfinder_paths/core/utils/__init__.py +1 -0
- wayfinder_paths/core/utils/evm_helpers.py +206 -0
- wayfinder_paths/core/utils/wallets.py +77 -0
- wayfinder_paths/core/wallets/README.md +91 -0
- wayfinder_paths/core/wallets/WalletManager.py +56 -0
- wayfinder_paths/core/wallets/__init__.py +7 -0
- wayfinder_paths/policies/enso.py +17 -0
- wayfinder_paths/policies/erc20.py +34 -0
- wayfinder_paths/policies/evm.py +21 -0
- wayfinder_paths/policies/hyper_evm.py +19 -0
- wayfinder_paths/policies/hyperlend.py +12 -0
- wayfinder_paths/policies/hyperliquid.py +30 -0
- wayfinder_paths/policies/moonwell.py +54 -0
- wayfinder_paths/policies/prjx.py +30 -0
- wayfinder_paths/policies/util.py +27 -0
- wayfinder_paths/run_strategy.py +411 -0
- wayfinder_paths/scripts/__init__.py +0 -0
- wayfinder_paths/scripts/create_strategy.py +181 -0
- wayfinder_paths/scripts/make_wallets.py +169 -0
- wayfinder_paths/scripts/run_strategy.py +124 -0
- wayfinder_paths/scripts/validate_manifests.py +213 -0
- wayfinder_paths/strategies/__init__.py +0 -0
- wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
- wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
- wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
- wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
- wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
- wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
- wayfinder_paths/strategies/config.py +85 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +100 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +8 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2270 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +352 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +96 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/examples.json +17 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1810 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +520 -0
- wayfinder_paths/templates/adapter/README.md +105 -0
- wayfinder_paths/templates/adapter/adapter.py +26 -0
- wayfinder_paths/templates/adapter/examples.json +8 -0
- wayfinder_paths/templates/adapter/manifest.yaml +6 -0
- wayfinder_paths/templates/adapter/test_adapter.py +49 -0
- wayfinder_paths/templates/strategy/README.md +153 -0
- wayfinder_paths/templates/strategy/examples.json +11 -0
- wayfinder_paths/templates/strategy/manifest.yaml +8 -0
- wayfinder_paths/templates/strategy/strategy.py +57 -0
- wayfinder_paths/templates/strategy/test_strategy.py +197 -0
- wayfinder_paths/tests/__init__.py +0 -0
- wayfinder_paths/tests/test_smoke_manifest.py +48 -0
- wayfinder_paths/tests/test_test_coverage.py +212 -0
- wayfinder_paths/tests/test_utils.py +64 -0
- wayfinder_paths-0.1.7.dist-info/LICENSE +21 -0
- wayfinder_paths-0.1.7.dist-info/METADATA +777 -0
- wayfinder_paths-0.1.7.dist-info/RECORD +149 -0
- wayfinder_paths-0.1.7.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,4522 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import math
|
|
12
|
+
import random
|
|
13
|
+
import time
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from decimal import ROUND_UP, Decimal, getcontext
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from statistics import fmean, mean, pstdev
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from wayfinder_paths.adapters.balance_adapter.adapter import BalanceAdapter
|
|
21
|
+
from wayfinder_paths.adapters.hyperliquid_adapter.adapter import (
|
|
22
|
+
HYPERLIQUID_BRIDGE_ADDRESS,
|
|
23
|
+
HyperliquidAdapter,
|
|
24
|
+
SimpleCache,
|
|
25
|
+
)
|
|
26
|
+
from wayfinder_paths.adapters.hyperliquid_adapter.executor import (
|
|
27
|
+
HyperliquidExecutor,
|
|
28
|
+
LocalHyperliquidExecutor,
|
|
29
|
+
)
|
|
30
|
+
from wayfinder_paths.adapters.hyperliquid_adapter.paired_filler import (
|
|
31
|
+
MIN_NOTIONAL_USD,
|
|
32
|
+
FillConfig,
|
|
33
|
+
PairedFiller,
|
|
34
|
+
)
|
|
35
|
+
from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
|
|
36
|
+
normalize_l2_book as hl_normalize_l2_book,
|
|
37
|
+
)
|
|
38
|
+
from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
|
|
39
|
+
round_size_for_asset as hl_round_size_for_asset,
|
|
40
|
+
)
|
|
41
|
+
from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
|
|
42
|
+
size_step as hl_size_step,
|
|
43
|
+
)
|
|
44
|
+
from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
|
|
45
|
+
spot_index_from_asset_id as hl_spot_index_from_asset_id,
|
|
46
|
+
)
|
|
47
|
+
from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
|
|
48
|
+
sz_decimals_for_asset as hl_sz_decimals_for_asset,
|
|
49
|
+
)
|
|
50
|
+
from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
|
|
51
|
+
usd_depth_in_band as hl_usd_depth_in_band,
|
|
52
|
+
)
|
|
53
|
+
from wayfinder_paths.adapters.ledger_adapter.adapter import LedgerAdapter
|
|
54
|
+
from wayfinder_paths.adapters.token_adapter.adapter import TokenAdapter
|
|
55
|
+
from wayfinder_paths.core.analytics import (
|
|
56
|
+
block_bootstrap_paths as analytics_block_bootstrap_paths,
|
|
57
|
+
)
|
|
58
|
+
from wayfinder_paths.core.analytics import (
|
|
59
|
+
percentile as analytics_percentile,
|
|
60
|
+
)
|
|
61
|
+
from wayfinder_paths.core.analytics import (
|
|
62
|
+
rolling_min_sum as analytics_rolling_min_sum,
|
|
63
|
+
)
|
|
64
|
+
from wayfinder_paths.core.analytics import (
|
|
65
|
+
z_from_conf as analytics_z_from_conf,
|
|
66
|
+
)
|
|
67
|
+
from wayfinder_paths.core.services.base import Web3Service
|
|
68
|
+
from wayfinder_paths.core.services.local_token_txn import LocalTokenTxnService
|
|
69
|
+
from wayfinder_paths.core.services.web3_service import DefaultWeb3Service
|
|
70
|
+
from wayfinder_paths.core.strategies.descriptors import (
|
|
71
|
+
Complexity,
|
|
72
|
+
Directionality,
|
|
73
|
+
Frequency,
|
|
74
|
+
StratDescriptor,
|
|
75
|
+
TokenExposure,
|
|
76
|
+
Volatility,
|
|
77
|
+
)
|
|
78
|
+
from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
|
|
79
|
+
from wayfinder_paths.core.wallets.WalletManager import WalletManager
|
|
80
|
+
from wayfinder_paths.strategies.basis_trading_strategy.constants import (
|
|
81
|
+
USDC_ARBITRUM_TOKEN_ID,
|
|
82
|
+
)
|
|
83
|
+
from wayfinder_paths.strategies.basis_trading_strategy.snapshot_mixin import (
|
|
84
|
+
BasisSnapshotMixin,
|
|
85
|
+
)
|
|
86
|
+
from wayfinder_paths.strategies.basis_trading_strategy.types import (
|
|
87
|
+
BasisCandidate,
|
|
88
|
+
BasisPosition,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Set decimal precision for precise price/size calculations
|
|
92
|
+
getcontext().prec = 28
|
|
93
|
+
|
|
94
|
+
# Hyperliquid price decimal limits
|
|
95
|
+
MAX_DECIMALS_PERP = 6
|
|
96
|
+
MAX_DECIMALS_SPOT = 8
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _d(x: float | Decimal | str) -> Decimal:
|
|
100
|
+
"""Convert to Decimal for precise calculations."""
|
|
101
|
+
return x if isinstance(x, Decimal) else Decimal(str(x))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
105
|
+
"""
|
|
106
|
+
Delta-neutral basis trading strategy on Hyperliquid.
|
|
107
|
+
|
|
108
|
+
Captures funding rate payments by maintaining offsetting spot long and
|
|
109
|
+
perpetual short positions. Uses historical funding rate and volatility
|
|
110
|
+
analysis to select optimal opportunities.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
name = "Basis Trading Strategy"
|
|
114
|
+
|
|
115
|
+
# Strategy parameters
|
|
116
|
+
MIN_DEPOSIT_USDC = 25
|
|
117
|
+
DEFAULT_LOOKBACK_DAYS = 30 # Supports up to ~208 days via chunked API calls
|
|
118
|
+
DEFAULT_CONFIDENCE = 0.975
|
|
119
|
+
DEFAULT_FEE_EPS = 0.003 # 0.3% fee buffer
|
|
120
|
+
DEFAULT_OI_FLOOR = 100_000.0 # Min OI in USD (matches Django)
|
|
121
|
+
DEFAULT_DAY_VLM_FLOOR = 100_000 # Min daily volume
|
|
122
|
+
DEFAULT_MAX_LEVERAGE = 2
|
|
123
|
+
GAS_MAXIMUM = 0.01 # ETH
|
|
124
|
+
DEFAULT_BOOTSTRAP_SIMS = 50
|
|
125
|
+
DEFAULT_BOOTSTRAP_BLOCK_HOURS = 48
|
|
126
|
+
|
|
127
|
+
# Liquidation and rebalance thresholds (from Django funding_rate_strategy.py)
|
|
128
|
+
LIQUIDATION_REBALANCE_THRESHOLD = 0.75 # Trigger rebalance at 75% to liquidation
|
|
129
|
+
LIQUIDATION_STOP_LOSS_THRESHOLD = 0.90 # Stop-loss at 90% to liquidation (closer)
|
|
130
|
+
FUNDING_REBALANCE_THRESHOLD = 0.02 # Rebalance when funding hits 2% gains
|
|
131
|
+
|
|
132
|
+
# Position tolerances (from Django hyperliquid_adapter.py)
|
|
133
|
+
SPOT_POSITION_DUST_TOLERANCE = 0.04 # ±4% size drift allowed
|
|
134
|
+
MIN_UNUSED_USD = 5.0 # Minimum idle USD threshold
|
|
135
|
+
UNUSED_REL_EPS = 0.01 # 1% of bankroll idle threshold
|
|
136
|
+
|
|
137
|
+
# Rotation cooldown
|
|
138
|
+
ROTATION_MIN_INTERVAL_DAYS = 14 # 14 days between rotations
|
|
139
|
+
|
|
140
|
+
# Builder fee for Hyperliquid trades
|
|
141
|
+
HYPE_FEE_WALLET: str = "0xaA1D89f333857eD78F8434CC4f896A9293EFE65c"
|
|
142
|
+
HYPE_PRO_FEE: int = 30 # in tenths of basis points (0.03% = 3 bps)
|
|
143
|
+
DEFAULT_BUILDER_FEE: dict[str, Any] = {"b": HYPE_FEE_WALLET, "f": HYPE_PRO_FEE}
|
|
144
|
+
|
|
145
|
+
INFO = StratDescriptor(
|
|
146
|
+
description="""Delta-neutral basis trading on Hyperliquid that captures funding rate payments.
|
|
147
|
+
**What it does:** Analyzes historical funding rates, price volatility, and liquidity across
|
|
148
|
+
Hyperliquid markets to identify optimal basis trading opportunities. Opens matched spot long
|
|
149
|
+
and perpetual short positions to capture positive funding while remaining market neutral.
|
|
150
|
+
**Exposure type:** Delta-neutral - equal long spot and short perp exposure cancels price risk.
|
|
151
|
+
**Chains:** Hyperliquid (Arbitrum for deposits).
|
|
152
|
+
**Deposit/Withdrawal:** Deposits USDC which is used to open basis positions.
|
|
153
|
+
Withdrawals close all positions and return USDC to main wallet.
|
|
154
|
+
**Risk:** Funding rates can flip negative; liquidation risk if leverage too high.
|
|
155
|
+
""",
|
|
156
|
+
summary=(
|
|
157
|
+
"Automated delta-neutral basis trading on Hyperliquid, capturing funding rate payments "
|
|
158
|
+
"through matched spot long / perp short positions with intelligent leverage sizing."
|
|
159
|
+
),
|
|
160
|
+
gas_token_symbol="ETH",
|
|
161
|
+
gas_token_id="ethereum-arbitrum",
|
|
162
|
+
deposit_token_id="usd-coin-arbitrum",
|
|
163
|
+
minimum_net_deposit=MIN_DEPOSIT_USDC,
|
|
164
|
+
gas_maximum=GAS_MAXIMUM,
|
|
165
|
+
gas_threshold=GAS_MAXIMUM / 3,
|
|
166
|
+
volatility=Volatility.MEDIUM,
|
|
167
|
+
volatility_description_short="Delta-neutral but funding can flip negative.",
|
|
168
|
+
directionality=Directionality.DELTA_NEUTRAL,
|
|
169
|
+
directionality_description="Matched spot long and perp short cancels directional exposure.",
|
|
170
|
+
complexity=Complexity.MEDIUM,
|
|
171
|
+
complexity_description="Requires understanding of funding rates and leverage.",
|
|
172
|
+
token_exposure=TokenExposure.STABLECOINS,
|
|
173
|
+
token_exposure_description="Capital in USDC, exposed to crypto through hedged positions.",
|
|
174
|
+
frequency=Frequency.LOW,
|
|
175
|
+
frequency_description="Positions held for days/weeks to accumulate funding.",
|
|
176
|
+
return_drivers=["funding rate", "basis spread"],
|
|
177
|
+
config={
|
|
178
|
+
"deposit": {
|
|
179
|
+
"description": "Deposit USDC to fund basis trading positions.",
|
|
180
|
+
"parameters": {
|
|
181
|
+
"main_token_amount": {
|
|
182
|
+
"type": "float",
|
|
183
|
+
"unit": "USDC",
|
|
184
|
+
"description": "Amount of USDC to allocate.",
|
|
185
|
+
"minimum": MIN_DEPOSIT_USDC,
|
|
186
|
+
},
|
|
187
|
+
"gas_token_amount": {
|
|
188
|
+
"type": "float",
|
|
189
|
+
"unit": "ETH",
|
|
190
|
+
"description": "Amount of ETH for gas.",
|
|
191
|
+
"minimum": 0.0,
|
|
192
|
+
"maximum": GAS_MAXIMUM,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
"update": {
|
|
197
|
+
"description": "Analyze markets and open/monitor positions.",
|
|
198
|
+
},
|
|
199
|
+
"withdraw": {
|
|
200
|
+
"description": "Close all positions and return USDC to main wallet.",
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
config: dict[str, Any] | None = None,
|
|
208
|
+
*,
|
|
209
|
+
main_wallet: dict[str, Any] | None = None,
|
|
210
|
+
strategy_wallet: dict[str, Any] | None = None,
|
|
211
|
+
simulation: bool = False,
|
|
212
|
+
web3_service: Web3Service | None = None,
|
|
213
|
+
hyperliquid_executor: HyperliquidExecutor | None = None,
|
|
214
|
+
api_key: str | None = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
super().__init__(api_key=api_key)
|
|
217
|
+
|
|
218
|
+
merged_config = dict(config or {})
|
|
219
|
+
if main_wallet:
|
|
220
|
+
merged_config["main_wallet"] = main_wallet
|
|
221
|
+
if strategy_wallet:
|
|
222
|
+
merged_config["strategy_wallet"] = strategy_wallet
|
|
223
|
+
self.config = merged_config
|
|
224
|
+
self.simulation = simulation
|
|
225
|
+
|
|
226
|
+
# Position tracking
|
|
227
|
+
self.current_position: BasisPosition | None = None
|
|
228
|
+
self.deposit_amount: float = 0.0
|
|
229
|
+
|
|
230
|
+
# Builder fee for Hyperliquid trades (from config or default)
|
|
231
|
+
# Format: {"b": "0x...", "f": 10} where 'b' is address, 'f' is fee in bps
|
|
232
|
+
self.builder_fee: dict[str, Any] | None = self.config.get(
|
|
233
|
+
"builder_fee", self.DEFAULT_BUILDER_FEE
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Initialize cache
|
|
237
|
+
self._cache = SimpleCache()
|
|
238
|
+
self._margin_table_cache: dict[int, list[dict[str, float]]] = {}
|
|
239
|
+
|
|
240
|
+
# Adapters (some are optional for analysis-only usage).
|
|
241
|
+
self.balance_adapter: BalanceAdapter | None = None
|
|
242
|
+
self.token_adapter: TokenAdapter | None = None
|
|
243
|
+
self.ledger_adapter: LedgerAdapter | None = None
|
|
244
|
+
self.hyperliquid_adapter: HyperliquidAdapter | None = None
|
|
245
|
+
|
|
246
|
+
adapter_config = {
|
|
247
|
+
"main_wallet": self.config.get("main_wallet"),
|
|
248
|
+
"strategy_wallet": self.config.get("strategy_wallet"),
|
|
249
|
+
"strategy": self.config,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# Create Hyperliquid executor if not provided and not in simulation.
|
|
253
|
+
# This is only required for placing/canceling orders (not market reads).
|
|
254
|
+
hl_executor = hyperliquid_executor
|
|
255
|
+
if hl_executor is None and not self.simulation:
|
|
256
|
+
try:
|
|
257
|
+
hl_executor = LocalHyperliquidExecutor(config=adapter_config)
|
|
258
|
+
self.logger.info("Created LocalHyperliquidExecutor for real execution")
|
|
259
|
+
except Exception as e:
|
|
260
|
+
self.logger.warning(
|
|
261
|
+
f"Could not create LocalHyperliquidExecutor: {e}. "
|
|
262
|
+
"Real Hyperliquid execution will not be available."
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Hyperliquid market data adapter should be usable even when wallet/web3
|
|
266
|
+
# configuration is missing (e.g. local --action analyze).
|
|
267
|
+
try:
|
|
268
|
+
self.hyperliquid_adapter = HyperliquidAdapter(
|
|
269
|
+
config=adapter_config,
|
|
270
|
+
simulation=self.simulation,
|
|
271
|
+
executor=hl_executor,
|
|
272
|
+
)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
self.logger.warning(f"Could not initialize HyperliquidAdapter: {e}")
|
|
275
|
+
|
|
276
|
+
# Other adapters require a configured wallet provider / web3 service.
|
|
277
|
+
try:
|
|
278
|
+
if web3_service is None:
|
|
279
|
+
wallet_provider = WalletManager.get_provider(adapter_config)
|
|
280
|
+
tx_adapter = LocalTokenTxnService(
|
|
281
|
+
adapter_config,
|
|
282
|
+
wallet_provider=wallet_provider,
|
|
283
|
+
simulation=self.simulation,
|
|
284
|
+
)
|
|
285
|
+
web3_service = DefaultWeb3Service(
|
|
286
|
+
wallet_provider=wallet_provider, evm_transactions=tx_adapter
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
self.web3_service = web3_service
|
|
290
|
+
self.balance_adapter = BalanceAdapter(
|
|
291
|
+
adapter_config, web3_service=web3_service
|
|
292
|
+
)
|
|
293
|
+
self.token_adapter = TokenAdapter()
|
|
294
|
+
self.ledger_adapter = LedgerAdapter()
|
|
295
|
+
except Exception as e:
|
|
296
|
+
self.logger.warning(f"Wallet/web3 adapter initialization deferred: {e}")
|
|
297
|
+
|
|
298
|
+
adapters: list[Any] = []
|
|
299
|
+
if self.balance_adapter is not None:
|
|
300
|
+
adapters.append(self.balance_adapter)
|
|
301
|
+
if self.token_adapter is not None:
|
|
302
|
+
adapters.append(self.token_adapter)
|
|
303
|
+
if self.ledger_adapter is not None:
|
|
304
|
+
adapters.append(self.ledger_adapter)
|
|
305
|
+
if self.hyperliquid_adapter is not None:
|
|
306
|
+
adapters.append(self.hyperliquid_adapter)
|
|
307
|
+
if adapters:
|
|
308
|
+
self.register_adapters(adapters)
|
|
309
|
+
|
|
310
|
+
async def setup(self) -> None:
|
|
311
|
+
"""Initialize strategy state from chain/ledger and discover existing positions."""
|
|
312
|
+
self.logger.info("Starting BasisTradingStrategy setup")
|
|
313
|
+
start_time = time.time()
|
|
314
|
+
|
|
315
|
+
await super().setup()
|
|
316
|
+
|
|
317
|
+
# Get net deposit from ledger
|
|
318
|
+
try:
|
|
319
|
+
success, deposit_data = await self.ledger_adapter.get_strategy_net_deposit(
|
|
320
|
+
wallet_address=self._get_strategy_wallet_address()
|
|
321
|
+
)
|
|
322
|
+
if success and deposit_data:
|
|
323
|
+
self.deposit_amount = float(deposit_data.get("net_deposit", 0) or 0)
|
|
324
|
+
except Exception as e:
|
|
325
|
+
self.logger.warning(f"Could not fetch deposit data: {e}")
|
|
326
|
+
|
|
327
|
+
# Discover existing positions from Hyperliquid (critical for restart recovery)
|
|
328
|
+
try:
|
|
329
|
+
await self._discover_existing_position()
|
|
330
|
+
except Exception as e:
|
|
331
|
+
self.logger.warning(f"Could not discover existing positions: {e}")
|
|
332
|
+
|
|
333
|
+
elapsed = time.time() - start_time
|
|
334
|
+
self.logger.info(f"BasisTradingStrategy setup completed in {elapsed:.2f}s")
|
|
335
|
+
|
|
336
|
+
async def _discover_existing_position(self) -> None:
|
|
337
|
+
"""
|
|
338
|
+
Discover existing delta-neutral position from Hyperliquid state.
|
|
339
|
+
|
|
340
|
+
This is critical for restart recovery - we must not open new positions
|
|
341
|
+
if one already exists on-chain.
|
|
342
|
+
"""
|
|
343
|
+
address = self._get_strategy_wallet_address()
|
|
344
|
+
|
|
345
|
+
# Get perp positions
|
|
346
|
+
success, user_state = await self.hyperliquid_adapter.get_user_state(address)
|
|
347
|
+
if not success:
|
|
348
|
+
self.logger.warning("Could not fetch user state for position discovery")
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
asset_positions = user_state.get("assetPositions", [])
|
|
352
|
+
if not asset_positions:
|
|
353
|
+
self.logger.info("No existing perp positions found")
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
# Find SHORT perp position (basis trading uses short perp)
|
|
357
|
+
perp_position = None
|
|
358
|
+
for pos_wrapper in asset_positions:
|
|
359
|
+
pos = pos_wrapper.get("position", {})
|
|
360
|
+
szi = float(pos.get("szi", 0))
|
|
361
|
+
if szi < 0: # Short position
|
|
362
|
+
perp_position = pos
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
if not perp_position:
|
|
366
|
+
self.logger.info("No short perp position found")
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
coin = perp_position.get("coin")
|
|
370
|
+
perp_size = abs(float(perp_position.get("szi", 0)))
|
|
371
|
+
entry_px = float(perp_position.get("entryPx", 0))
|
|
372
|
+
|
|
373
|
+
# Get spot positions to find matching spot leg
|
|
374
|
+
success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
|
|
375
|
+
address
|
|
376
|
+
)
|
|
377
|
+
if not success:
|
|
378
|
+
self.logger.warning(
|
|
379
|
+
f"Found perp position on {coin} but could not fetch spot state"
|
|
380
|
+
)
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# Find matching spot position
|
|
384
|
+
spot_position = None
|
|
385
|
+
spot_balances = spot_state.get("balances", [])
|
|
386
|
+
for bal in spot_balances:
|
|
387
|
+
bal_coin = bal.get("coin", "")
|
|
388
|
+
# Match coin name (spot might have different naming)
|
|
389
|
+
if (
|
|
390
|
+
bal_coin == coin
|
|
391
|
+
or bal_coin.startswith(coin)
|
|
392
|
+
or coin.startswith(bal_coin.replace("U", ""))
|
|
393
|
+
):
|
|
394
|
+
total = float(bal.get("total", 0))
|
|
395
|
+
if total > 0:
|
|
396
|
+
spot_position = bal
|
|
397
|
+
break
|
|
398
|
+
|
|
399
|
+
if not spot_position:
|
|
400
|
+
self.logger.warning(
|
|
401
|
+
f"Found perp position on {coin} but no matching spot position - "
|
|
402
|
+
"may have partial exposure"
|
|
403
|
+
)
|
|
404
|
+
# Still track it so we don't open another position
|
|
405
|
+
spot_size = 0.0
|
|
406
|
+
else:
|
|
407
|
+
spot_size = float(spot_position.get("total", 0))
|
|
408
|
+
|
|
409
|
+
# Get asset IDs
|
|
410
|
+
perp_asset_id = self.hyperliquid_adapter.coin_to_asset.get(coin)
|
|
411
|
+
# Spot asset ID: look up from spot meta or estimate
|
|
412
|
+
spot_asset_id = None
|
|
413
|
+
success, spot_meta = await self.hyperliquid_adapter.get_spot_meta()
|
|
414
|
+
if success:
|
|
415
|
+
tokens = spot_meta.get("tokens", [])
|
|
416
|
+
universe = spot_meta.get("universe", [])
|
|
417
|
+
for pair in universe:
|
|
418
|
+
base_idx = pair["tokens"][0]
|
|
419
|
+
for t in tokens:
|
|
420
|
+
if t["index"] == base_idx:
|
|
421
|
+
# Check if this token matches our coin
|
|
422
|
+
if (
|
|
423
|
+
t["name"] == coin
|
|
424
|
+
or t["name"] == f"U{coin}"
|
|
425
|
+
or t["name"].replace("U", "") == coin
|
|
426
|
+
):
|
|
427
|
+
spot_asset_id = pair["index"] + 10000
|
|
428
|
+
break
|
|
429
|
+
if spot_asset_id:
|
|
430
|
+
break
|
|
431
|
+
|
|
432
|
+
# Reconstruct position state
|
|
433
|
+
self.current_position = BasisPosition(
|
|
434
|
+
coin=coin,
|
|
435
|
+
spot_asset_id=spot_asset_id or 0,
|
|
436
|
+
perp_asset_id=perp_asset_id or 0,
|
|
437
|
+
spot_amount=spot_size,
|
|
438
|
+
perp_amount=perp_size,
|
|
439
|
+
entry_price=entry_px,
|
|
440
|
+
leverage=2, # Default, actual leverage can be inferred
|
|
441
|
+
entry_timestamp=int(time.time() * 1000), # Approximate
|
|
442
|
+
funding_collected=abs(
|
|
443
|
+
float(perp_position.get("cumFunding", {}).get("sinceOpen", 0))
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Update deposit amount from actual account value if not set
|
|
448
|
+
if self.deposit_amount <= 0:
|
|
449
|
+
margin_summary = user_state.get("marginSummary", {})
|
|
450
|
+
self.deposit_amount = float(margin_summary.get("accountValue", 0))
|
|
451
|
+
|
|
452
|
+
self.logger.info(
|
|
453
|
+
f"Discovered existing position: {coin} "
|
|
454
|
+
f"(perp={perp_size:.4f}, spot={spot_size:.4f}, entry=${entry_px:.2f})"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
async def deposit(
|
|
458
|
+
self,
|
|
459
|
+
main_token_amount: float = 0.0,
|
|
460
|
+
gas_token_amount: float = 0.0,
|
|
461
|
+
) -> StatusTuple:
|
|
462
|
+
"""
|
|
463
|
+
Deposit USDC to Hyperliquid L1 for basis trading.
|
|
464
|
+
|
|
465
|
+
Sends USDC from the strategy wallet to the Hyperliquid bridge address on Arbitrum,
|
|
466
|
+
then waits for it to be credited on Hyperliquid L1.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
main_token_amount: Amount of USDC to deposit
|
|
470
|
+
gas_token_amount: Amount of ETH for gas (unused, kept for interface compatibility)
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
StatusTuple (success, message)
|
|
474
|
+
"""
|
|
475
|
+
if main_token_amount < self.MIN_DEPOSIT_USDC:
|
|
476
|
+
return (False, f"Minimum deposit is {self.MIN_DEPOSIT_USDC} USDC")
|
|
477
|
+
|
|
478
|
+
if gas_token_amount > self.GAS_MAXIMUM:
|
|
479
|
+
return (False, f"Gas amount exceeds maximum {self.GAS_MAXIMUM} ETH")
|
|
480
|
+
|
|
481
|
+
self.logger.info(f"Depositing {main_token_amount} USDC to Hyperliquid L1")
|
|
482
|
+
|
|
483
|
+
# Transfer ETH for gas if requested
|
|
484
|
+
if gas_token_amount > 0:
|
|
485
|
+
main_address = self._get_main_wallet_address()
|
|
486
|
+
strategy_address = self._get_strategy_wallet_address()
|
|
487
|
+
self.logger.info(
|
|
488
|
+
f"Transferring {gas_token_amount} ETH for gas from main wallet "
|
|
489
|
+
f"({main_address}) to strategy wallet ({strategy_address})"
|
|
490
|
+
)
|
|
491
|
+
(
|
|
492
|
+
gas_ok,
|
|
493
|
+
gas_res,
|
|
494
|
+
) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
|
|
495
|
+
token_id="ethereum-arbitrum", # Native ETH on Arbitrum
|
|
496
|
+
amount=gas_token_amount,
|
|
497
|
+
strategy_name=self.name or "basis_trading_strategy",
|
|
498
|
+
skip_ledger=True,
|
|
499
|
+
)
|
|
500
|
+
if not gas_ok:
|
|
501
|
+
self.logger.error(f"Failed to transfer ETH for gas: {gas_res}")
|
|
502
|
+
return (False, f"Failed to transfer ETH for gas: {gas_res}")
|
|
503
|
+
self.logger.info(f"Gas transfer successful: {gas_res}")
|
|
504
|
+
|
|
505
|
+
# Simulation mode - just track the deposit
|
|
506
|
+
if self.simulation:
|
|
507
|
+
self.logger.info(
|
|
508
|
+
f"[SIMULATION] Would send {main_token_amount} USDC to Hyperliquid bridge"
|
|
509
|
+
)
|
|
510
|
+
self.deposit_amount = main_token_amount
|
|
511
|
+
return (
|
|
512
|
+
True,
|
|
513
|
+
f"[SIMULATION] Deposited {main_token_amount} USDC. "
|
|
514
|
+
f"Call update() to analyze and open positions.",
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Real deposit: ensure funds are in the strategy wallet, then send USDC to bridge.
|
|
518
|
+
try:
|
|
519
|
+
main_address = self._get_main_wallet_address()
|
|
520
|
+
strategy_wallet = self.config.get("strategy_wallet")
|
|
521
|
+
strategy_address = self._get_strategy_wallet_address()
|
|
522
|
+
|
|
523
|
+
# Check if strategy wallet already has sufficient USDC
|
|
524
|
+
(
|
|
525
|
+
strategy_balance_ok,
|
|
526
|
+
strategy_balance,
|
|
527
|
+
) = await self.balance_adapter.get_balance(
|
|
528
|
+
token_id=USDC_ARBITRUM_TOKEN_ID,
|
|
529
|
+
wallet_address=strategy_address,
|
|
530
|
+
)
|
|
531
|
+
strategy_usdc = 0.0
|
|
532
|
+
if strategy_balance_ok and strategy_balance:
|
|
533
|
+
# Balance is returned in raw units, USDC has 6 decimals
|
|
534
|
+
strategy_usdc = float(strategy_balance) / 1e6
|
|
535
|
+
|
|
536
|
+
need_to_move = main_token_amount - strategy_usdc
|
|
537
|
+
if main_address.lower() != strategy_address.lower() and need_to_move > 0.01:
|
|
538
|
+
self.logger.info(
|
|
539
|
+
f"Moving {need_to_move:.2f} USDC from main wallet ({main_address}) "
|
|
540
|
+
f"to strategy wallet ({strategy_address}) [existing: {strategy_usdc:.2f}]"
|
|
541
|
+
)
|
|
542
|
+
(
|
|
543
|
+
move_ok,
|
|
544
|
+
move_res,
|
|
545
|
+
) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
|
|
546
|
+
token_id=USDC_ARBITRUM_TOKEN_ID,
|
|
547
|
+
amount=need_to_move,
|
|
548
|
+
strategy_name=self.name or "basis_trading_strategy",
|
|
549
|
+
skip_ledger=True,
|
|
550
|
+
)
|
|
551
|
+
if not move_ok:
|
|
552
|
+
self.logger.error(
|
|
553
|
+
f"Failed to move USDC into strategy wallet: {move_res}"
|
|
554
|
+
)
|
|
555
|
+
return (
|
|
556
|
+
False,
|
|
557
|
+
f"Failed to move USDC into strategy wallet: {move_res}",
|
|
558
|
+
)
|
|
559
|
+
elif strategy_usdc >= main_token_amount:
|
|
560
|
+
self.logger.info(
|
|
561
|
+
f"Strategy wallet already has {strategy_usdc:.2f} USDC, skipping transfer from main"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
self.logger.info(
|
|
565
|
+
f"Sending {main_token_amount} USDC from strategy wallet ({strategy_address}) "
|
|
566
|
+
f"to Hyperliquid bridge ({HYPERLIQUID_BRIDGE_ADDRESS})"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Send USDC to bridge address (deposit credits the sender address on Hyperliquid)
|
|
570
|
+
success, result = await self.balance_adapter.send_to_address(
|
|
571
|
+
token_id=USDC_ARBITRUM_TOKEN_ID,
|
|
572
|
+
amount=main_token_amount,
|
|
573
|
+
from_wallet=strategy_wallet,
|
|
574
|
+
to_address=HYPERLIQUID_BRIDGE_ADDRESS,
|
|
575
|
+
skip_ledger=True, # We'll record after HL credits the deposit
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if not success:
|
|
579
|
+
self.logger.error(f"Failed to send USDC to bridge: {result}")
|
|
580
|
+
return (False, f"Failed to send USDC to bridge: {result}")
|
|
581
|
+
|
|
582
|
+
self.logger.info(f"USDC sent to bridge, tx: {result}")
|
|
583
|
+
|
|
584
|
+
# Wait for Hyperliquid to credit the deposit
|
|
585
|
+
self.logger.info("Waiting for Hyperliquid to credit the deposit...")
|
|
586
|
+
|
|
587
|
+
(
|
|
588
|
+
deposit_confirmed,
|
|
589
|
+
final_balance,
|
|
590
|
+
) = await self.hyperliquid_adapter.wait_for_deposit(
|
|
591
|
+
address=strategy_address,
|
|
592
|
+
expected_increase=main_token_amount,
|
|
593
|
+
timeout_s=180, # 3 minutes for initial deposit
|
|
594
|
+
poll_interval_s=10,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
if not deposit_confirmed:
|
|
598
|
+
self.logger.warning(
|
|
599
|
+
f"Deposit not confirmed within timeout. "
|
|
600
|
+
f"Current HL balance: ${final_balance:.2f}. "
|
|
601
|
+
f"Deposit may still be processing."
|
|
602
|
+
)
|
|
603
|
+
# Still track the deposit amount since we sent it
|
|
604
|
+
self.deposit_amount = main_token_amount
|
|
605
|
+
return (
|
|
606
|
+
True,
|
|
607
|
+
f"Sent {main_token_amount} USDC to bridge. Deposit still processing. "
|
|
608
|
+
f"Current HL balance: ${final_balance:.2f}",
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
self.deposit_amount = main_token_amount
|
|
612
|
+
|
|
613
|
+
# Record in ledger
|
|
614
|
+
try:
|
|
615
|
+
await self.ledger_adapter.record_deposit(
|
|
616
|
+
wallet_address=strategy_address,
|
|
617
|
+
chain_id=42161, # Arbitrum
|
|
618
|
+
token_address="hyperliquid-vault-usd", # Synthetic address for HL USD
|
|
619
|
+
token_amount=str(main_token_amount),
|
|
620
|
+
usd_value=main_token_amount,
|
|
621
|
+
data={"destination": "hyperliquid_l1"},
|
|
622
|
+
strategy_name=self.name,
|
|
623
|
+
)
|
|
624
|
+
except Exception as e:
|
|
625
|
+
self.logger.warning(f"Failed to record deposit in ledger: {e}")
|
|
626
|
+
|
|
627
|
+
return (
|
|
628
|
+
True,
|
|
629
|
+
f"Deposited {main_token_amount} USDC to Hyperliquid L1. "
|
|
630
|
+
f"Balance: ${final_balance:.2f}. Call update() to open positions.",
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
except Exception as e:
|
|
634
|
+
self.logger.error(f"Deposit failed: {e}")
|
|
635
|
+
return (False, f"Deposit failed: {e}")
|
|
636
|
+
|
|
637
|
+
async def update(self) -> StatusTuple:
|
|
638
|
+
"""
|
|
639
|
+
Analyze markets and manage positions.
|
|
640
|
+
|
|
641
|
+
- If no position exists, analyzes opportunities and opens the best one.
|
|
642
|
+
- If position exists, monitors and maintains it:
|
|
643
|
+
- Checks for rebalance conditions
|
|
644
|
+
- Verifies leg balance (spot == perp)
|
|
645
|
+
- Deploys any idle capital
|
|
646
|
+
- Ensures stop-loss orders are valid
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
StatusTuple (success, message)
|
|
650
|
+
"""
|
|
651
|
+
# If deposit_amount not set, try to detect from Hyperliquid balance
|
|
652
|
+
if self.deposit_amount <= 0:
|
|
653
|
+
address = self._get_strategy_wallet_address()
|
|
654
|
+
success, user_state = await self.hyperliquid_adapter.get_user_state(address)
|
|
655
|
+
if success:
|
|
656
|
+
margin_summary = user_state.get("marginSummary", {})
|
|
657
|
+
account_value = float(margin_summary.get("accountValue", 0))
|
|
658
|
+
if account_value > 1.0:
|
|
659
|
+
self.logger.info(
|
|
660
|
+
f"Detected ${account_value:.2f} on Hyperliquid, using as deposit amount"
|
|
661
|
+
)
|
|
662
|
+
self.deposit_amount = account_value
|
|
663
|
+
|
|
664
|
+
if self.deposit_amount <= 0:
|
|
665
|
+
return (False, "No deposit to manage. Call deposit() first.")
|
|
666
|
+
|
|
667
|
+
# If no position, find and open one
|
|
668
|
+
if self.current_position is None:
|
|
669
|
+
return await self._find_and_open_position()
|
|
670
|
+
|
|
671
|
+
# Monitor existing position (handles idle capital, leg balance, stop-loss)
|
|
672
|
+
return await self._monitor_position()
|
|
673
|
+
|
|
674
|
+
async def analyze(
|
|
675
|
+
self, deposit_usdc: float = 1000.0, verbose: bool = True
|
|
676
|
+
) -> dict[str, Any]:
|
|
677
|
+
"""
|
|
678
|
+
Analyze basis trading opportunities without executing.
|
|
679
|
+
|
|
680
|
+
Uses the Net-APY + stop-churn backtest solver with block-bootstrap
|
|
681
|
+
resampling (ported from Django's NetApyBasisTradingService).
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
deposit_usdc: Hypothetical deposit amount for sizing calculations (default $1000)
|
|
685
|
+
verbose: Include debug info about filtering
|
|
686
|
+
|
|
687
|
+
Returns:
|
|
688
|
+
Dict with opportunities sorted by net APY (includes bootstrap metrics)
|
|
689
|
+
"""
|
|
690
|
+
self.logger.info(
|
|
691
|
+
f"Analyzing basis opportunities for ${deposit_usdc} deposit..."
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
debug_info: dict[str, Any] = {}
|
|
695
|
+
|
|
696
|
+
try:
|
|
697
|
+
snapshot = self._snapshot_from_config()
|
|
698
|
+
if snapshot is not None:
|
|
699
|
+
try:
|
|
700
|
+
opportunities = self.opportunities_from_snapshot(
|
|
701
|
+
snapshot=snapshot, deposit_usdc=deposit_usdc
|
|
702
|
+
)
|
|
703
|
+
return {
|
|
704
|
+
"success": True,
|
|
705
|
+
"source": "snapshot",
|
|
706
|
+
"snapshot_path": None,
|
|
707
|
+
"snapshot_hour_bucket_utc": snapshot.get("hour_bucket_utc"),
|
|
708
|
+
"deposit_usdc": deposit_usdc,
|
|
709
|
+
"opportunities_count": len(opportunities),
|
|
710
|
+
"opportunities": opportunities,
|
|
711
|
+
"debug": debug_info if verbose else None,
|
|
712
|
+
}
|
|
713
|
+
except Exception as exc: # noqa: BLE001
|
|
714
|
+
self.logger.warning(
|
|
715
|
+
f"Failed to use in-memory snapshot: {exc}. Falling back to live analysis."
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
snapshot_path = self._snapshot_path_from_config()
|
|
719
|
+
if snapshot_path and Path(snapshot_path).exists():
|
|
720
|
+
try:
|
|
721
|
+
snapshot = self.load_snapshot_from_path(snapshot_path)
|
|
722
|
+
opportunities = self.opportunities_from_snapshot(
|
|
723
|
+
snapshot=snapshot, deposit_usdc=deposit_usdc
|
|
724
|
+
)
|
|
725
|
+
return {
|
|
726
|
+
"success": True,
|
|
727
|
+
"source": "snapshot",
|
|
728
|
+
"snapshot_path": snapshot_path,
|
|
729
|
+
"snapshot_hour_bucket_utc": snapshot.get("hour_bucket_utc"),
|
|
730
|
+
"deposit_usdc": deposit_usdc,
|
|
731
|
+
"opportunities_count": len(opportunities),
|
|
732
|
+
"opportunities": opportunities,
|
|
733
|
+
"debug": debug_info if verbose else None,
|
|
734
|
+
}
|
|
735
|
+
except Exception as exc: # noqa: BLE001
|
|
736
|
+
self.logger.warning(
|
|
737
|
+
f"Failed to load/use snapshot from {snapshot_path}: {exc}. "
|
|
738
|
+
"Falling back to live analysis."
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
# Get market data for debug info
|
|
742
|
+
(
|
|
743
|
+
success,
|
|
744
|
+
perps_ctx_pack,
|
|
745
|
+
) = await self.hyperliquid_adapter.get_meta_and_asset_ctxs()
|
|
746
|
+
if success:
|
|
747
|
+
perps_meta_list = perps_ctx_pack[0]["universe"]
|
|
748
|
+
debug_info["perp_count"] = len(perps_meta_list)
|
|
749
|
+
|
|
750
|
+
success, spot_meta = await self.hyperliquid_adapter.get_spot_meta()
|
|
751
|
+
if success:
|
|
752
|
+
spot_pairs = spot_meta.get("universe", [])
|
|
753
|
+
debug_info["spot_pair_count"] = len(spot_pairs)
|
|
754
|
+
|
|
755
|
+
bootstrap_sims = int(
|
|
756
|
+
self._cfg_get("bootstrap_sims", self.DEFAULT_BOOTSTRAP_SIMS) or 0
|
|
757
|
+
)
|
|
758
|
+
bootstrap_block_hours = int(
|
|
759
|
+
self._cfg_get(
|
|
760
|
+
"bootstrap_block_hours", self.DEFAULT_BOOTSTRAP_BLOCK_HOURS
|
|
761
|
+
)
|
|
762
|
+
or 0
|
|
763
|
+
)
|
|
764
|
+
bootstrap_seed = self._cfg_get("bootstrap_seed")
|
|
765
|
+
bootstrap_seed = int(bootstrap_seed) if bootstrap_seed is not None else None
|
|
766
|
+
self.logger.info(
|
|
767
|
+
f"Bootstrap settings: sims={bootstrap_sims}, block_hours={bootstrap_block_hours}, "
|
|
768
|
+
f"seed={'random' if bootstrap_seed is None else bootstrap_seed}"
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
opportunities = await self.solve_candidates_max_net_apy_with_stop(
|
|
772
|
+
deposit_usdc=deposit_usdc,
|
|
773
|
+
stop_frac=self.LIQUIDATION_REBALANCE_THRESHOLD,
|
|
774
|
+
lookback_days=self.DEFAULT_LOOKBACK_DAYS,
|
|
775
|
+
fee_eps=self.DEFAULT_FEE_EPS,
|
|
776
|
+
oi_floor=self.DEFAULT_OI_FLOOR,
|
|
777
|
+
day_vlm_floor=self.DEFAULT_DAY_VLM_FLOOR,
|
|
778
|
+
max_leverage=self.DEFAULT_MAX_LEVERAGE,
|
|
779
|
+
bootstrap_sims=bootstrap_sims,
|
|
780
|
+
bootstrap_block_hours=bootstrap_block_hours,
|
|
781
|
+
bootstrap_seed=bootstrap_seed,
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
if verbose:
|
|
785
|
+
self.logger.info(
|
|
786
|
+
f"Found {len(opportunities)} opportunities after all filters"
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
return {
|
|
790
|
+
"success": True,
|
|
791
|
+
"source": "live",
|
|
792
|
+
"deposit_usdc": deposit_usdc,
|
|
793
|
+
"bootstrap": {
|
|
794
|
+
"sims": bootstrap_sims,
|
|
795
|
+
"block_hours": bootstrap_block_hours,
|
|
796
|
+
"seed": bootstrap_seed,
|
|
797
|
+
},
|
|
798
|
+
"opportunities_count": len(opportunities),
|
|
799
|
+
"opportunities": opportunities,
|
|
800
|
+
"debug": debug_info if verbose else None,
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
except Exception as e:
|
|
804
|
+
self.logger.error(f"Analysis failed: {e}")
|
|
805
|
+
import traceback
|
|
806
|
+
|
|
807
|
+
traceback.print_exc()
|
|
808
|
+
return {
|
|
809
|
+
"success": False,
|
|
810
|
+
"error": str(e),
|
|
811
|
+
"opportunities": [],
|
|
812
|
+
"debug": debug_info if verbose else None,
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
def _cfg_get(self, key: str, default: Any | None = None) -> Any:
|
|
816
|
+
"""
|
|
817
|
+
Read a strategy config value.
|
|
818
|
+
|
|
819
|
+
Supports both flat configs (common in this repo) and nested configs
|
|
820
|
+
where strategy settings live under a "strategy" key.
|
|
821
|
+
"""
|
|
822
|
+
if key in self.config:
|
|
823
|
+
return self.config.get(key, default)
|
|
824
|
+
nested = self.config.get("strategy")
|
|
825
|
+
if isinstance(nested, dict) and key in nested:
|
|
826
|
+
return nested.get(key, default)
|
|
827
|
+
return default
|
|
828
|
+
|
|
829
|
+
async def withdraw(self, amount: float | None = None) -> StatusTuple:
|
|
830
|
+
"""
|
|
831
|
+
Close all positions and return funds to main wallet.
|
|
832
|
+
|
|
833
|
+
Handles funds in:
|
|
834
|
+
1. Strategy wallet on Arbitrum (USDC)
|
|
835
|
+
2. Hyperliquid L1 (positions + margin)
|
|
836
|
+
|
|
837
|
+
Args:
|
|
838
|
+
amount: Amount to withdraw (None = all)
|
|
839
|
+
|
|
840
|
+
Returns:
|
|
841
|
+
StatusTuple (success, message)
|
|
842
|
+
"""
|
|
843
|
+
address = self._get_strategy_wallet_address()
|
|
844
|
+
main_address = self._get_main_wallet_address()
|
|
845
|
+
usdc_token_id = "usd-coin-arbitrum"
|
|
846
|
+
total_withdrawn = 0.0
|
|
847
|
+
|
|
848
|
+
# Check for USDC already in strategy wallet on Arbitrum
|
|
849
|
+
strategy_usdc = 0.0
|
|
850
|
+
try:
|
|
851
|
+
success, balance_data = await self.balance_adapter.get_balance(
|
|
852
|
+
token_id=usdc_token_id,
|
|
853
|
+
wallet_address=address,
|
|
854
|
+
)
|
|
855
|
+
if success:
|
|
856
|
+
strategy_usdc = float(balance_data) / 1e6 # USDC has 6 decimals
|
|
857
|
+
except Exception as e:
|
|
858
|
+
self.logger.warning(f"Could not get strategy wallet balance: {e}")
|
|
859
|
+
|
|
860
|
+
# Get current Hyperliquid value (perp + spot)
|
|
861
|
+
hl_perp_value = 0.0
|
|
862
|
+
hl_spot_usdc = 0.0
|
|
863
|
+
success, user_state = await self.hyperliquid_adapter.get_user_state(address)
|
|
864
|
+
if success:
|
|
865
|
+
margin_summary = user_state.get("marginSummary", {})
|
|
866
|
+
hl_perp_value = float(margin_summary.get("accountValue", 0))
|
|
867
|
+
|
|
868
|
+
# Also check spot USDC balance
|
|
869
|
+
success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
|
|
870
|
+
address
|
|
871
|
+
)
|
|
872
|
+
if success:
|
|
873
|
+
for bal in spot_state.get("balances", []):
|
|
874
|
+
if bal.get("coin") == "USDC":
|
|
875
|
+
hl_spot_usdc = float(bal.get("total", 0))
|
|
876
|
+
break
|
|
877
|
+
|
|
878
|
+
hl_value = hl_perp_value + hl_spot_usdc
|
|
879
|
+
|
|
880
|
+
# Check if there's anything to withdraw
|
|
881
|
+
if strategy_usdc < 1.0 and hl_value < 1.0 and self.current_position is None:
|
|
882
|
+
return (False, "Nothing to withdraw")
|
|
883
|
+
|
|
884
|
+
# Step 0: Send any USDC already in strategy wallet to main wallet
|
|
885
|
+
if strategy_usdc > 1.0 and main_address.lower() != address.lower():
|
|
886
|
+
amount_to_send = strategy_usdc # Send full amount
|
|
887
|
+
self.logger.info(
|
|
888
|
+
f"Found ${strategy_usdc:.2f} USDC in strategy wallet, "
|
|
889
|
+
f"sending ${amount_to_send:.2f} to main wallet"
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
try:
|
|
893
|
+
(
|
|
894
|
+
send_success,
|
|
895
|
+
send_result,
|
|
896
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
897
|
+
token_id=usdc_token_id,
|
|
898
|
+
amount=amount_to_send,
|
|
899
|
+
strategy_name=self.name,
|
|
900
|
+
skip_ledger=False,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
if send_success:
|
|
904
|
+
self.logger.info(f"Sent ${amount_to_send:.2f} USDC to main wallet")
|
|
905
|
+
total_withdrawn += amount_to_send
|
|
906
|
+
else:
|
|
907
|
+
self.logger.warning(
|
|
908
|
+
f"Failed to send USDC to main wallet: {send_result}"
|
|
909
|
+
)
|
|
910
|
+
except Exception as e:
|
|
911
|
+
self.logger.error(f"Error sending USDC to main wallet: {e}")
|
|
912
|
+
|
|
913
|
+
# If nothing on Hyperliquid, we're done
|
|
914
|
+
if hl_value < 1.0 and self.current_position is None:
|
|
915
|
+
self.deposit_amount = 0
|
|
916
|
+
if total_withdrawn > 0:
|
|
917
|
+
return (
|
|
918
|
+
True,
|
|
919
|
+
f"Sent ${total_withdrawn:.2f} USDC to main wallet ({main_address})",
|
|
920
|
+
)
|
|
921
|
+
return (True, "No funds on Hyperliquid to withdraw")
|
|
922
|
+
|
|
923
|
+
# Close any open position
|
|
924
|
+
if self.current_position is not None:
|
|
925
|
+
close_success, close_msg = await self._close_position()
|
|
926
|
+
if not close_success:
|
|
927
|
+
return (False, f"Failed to close position: {close_msg}")
|
|
928
|
+
|
|
929
|
+
if self.simulation:
|
|
930
|
+
withdrawn = self.deposit_amount
|
|
931
|
+
self.deposit_amount = 0
|
|
932
|
+
return (True, f"[SIMULATION] Withdrew {withdrawn} USDC to main wallet")
|
|
933
|
+
|
|
934
|
+
# Step 1: Transfer any spot USDC to perp for withdrawal
|
|
935
|
+
success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
|
|
936
|
+
address
|
|
937
|
+
)
|
|
938
|
+
if success:
|
|
939
|
+
spot_balances = spot_state.get("balances", [])
|
|
940
|
+
for bal in spot_balances:
|
|
941
|
+
if bal.get("coin") == "USDC":
|
|
942
|
+
spot_usdc = float(bal.get("total", 0))
|
|
943
|
+
if spot_usdc > 1.0: # Only transfer if meaningful amount
|
|
944
|
+
self.logger.info(
|
|
945
|
+
f"Transferring ${spot_usdc:.2f} from spot to perp"
|
|
946
|
+
)
|
|
947
|
+
await self.hyperliquid_adapter.transfer_spot_to_perp(
|
|
948
|
+
amount=spot_usdc,
|
|
949
|
+
address=address,
|
|
950
|
+
)
|
|
951
|
+
break
|
|
952
|
+
|
|
953
|
+
# Step 2: Get updated perp balance for withdrawal (with retry)
|
|
954
|
+
# Wait a moment for transfers to settle
|
|
955
|
+
await asyncio.sleep(2)
|
|
956
|
+
|
|
957
|
+
withdrawable = 0.0
|
|
958
|
+
for attempt in range(3):
|
|
959
|
+
success, user_state = await self.hyperliquid_adapter.get_user_state(address)
|
|
960
|
+
if not success:
|
|
961
|
+
continue
|
|
962
|
+
|
|
963
|
+
# withdrawable is at top level of user_state, not in marginSummary
|
|
964
|
+
withdrawable = float(user_state.get("withdrawable", 0))
|
|
965
|
+
|
|
966
|
+
if withdrawable > 1.0:
|
|
967
|
+
break
|
|
968
|
+
|
|
969
|
+
self.logger.info(
|
|
970
|
+
f"Waiting for funds to be withdrawable (attempt {attempt + 1}/3)..."
|
|
971
|
+
)
|
|
972
|
+
await asyncio.sleep(3)
|
|
973
|
+
|
|
974
|
+
if withdrawable <= 0:
|
|
975
|
+
return (False, "No withdrawable funds available")
|
|
976
|
+
|
|
977
|
+
# Step 3: Withdraw from Hyperliquid to Arbitrum (strategy wallet)
|
|
978
|
+
self.logger.info(
|
|
979
|
+
f"Withdrawing ${withdrawable:.2f} from Hyperliquid to Arbitrum"
|
|
980
|
+
)
|
|
981
|
+
success, withdraw_result = await self.hyperliquid_adapter.withdraw(
|
|
982
|
+
amount=withdrawable,
|
|
983
|
+
address=address,
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
if not success:
|
|
987
|
+
return (False, f"Hyperliquid withdrawal failed: {withdraw_result}")
|
|
988
|
+
|
|
989
|
+
self.logger.info(f"Withdrawal initiated: {withdraw_result}")
|
|
990
|
+
|
|
991
|
+
# Step 4: Wait for withdrawal to appear on-chain
|
|
992
|
+
# Hyperliquid withdrawals typically take 5-15 minutes
|
|
993
|
+
self.logger.info("Waiting for withdrawal to appear on-chain...")
|
|
994
|
+
|
|
995
|
+
(
|
|
996
|
+
withdrawal_success,
|
|
997
|
+
withdrawals,
|
|
998
|
+
) = await self.hyperliquid_adapter.wait_for_withdrawal(
|
|
999
|
+
address=address,
|
|
1000
|
+
lookback_s=5,
|
|
1001
|
+
max_poll_time_s=20 * 60, # 20 minutes max
|
|
1002
|
+
poll_interval_s=10,
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
if not withdrawal_success or not withdrawals:
|
|
1006
|
+
return (
|
|
1007
|
+
False,
|
|
1008
|
+
f"Withdrawal of ${withdrawable:.2f} initiated but not confirmed on-chain. "
|
|
1009
|
+
"Check Hyperliquid explorer for status.",
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
# Get the withdrawal amount from the most recent tx
|
|
1013
|
+
tx_hash = list(withdrawals.keys())[-1]
|
|
1014
|
+
withdrawn_amount = withdrawals[tx_hash]
|
|
1015
|
+
self.logger.info(
|
|
1016
|
+
f"Withdrawal confirmed: tx={tx_hash}, amount=${withdrawn_amount:.2f}"
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
# Record withdrawal in ledger
|
|
1020
|
+
try:
|
|
1021
|
+
await self.ledger_adapter.record_withdrawal(
|
|
1022
|
+
wallet_address=address,
|
|
1023
|
+
chain_id=42161, # Arbitrum
|
|
1024
|
+
token_address="hyperliquid-vault-usd",
|
|
1025
|
+
token_amount=str(withdrawn_amount),
|
|
1026
|
+
usd_value=withdrawn_amount,
|
|
1027
|
+
data={
|
|
1028
|
+
"source": "hyperliquid_l1",
|
|
1029
|
+
"destination": "arbitrum",
|
|
1030
|
+
"tx_hash": tx_hash,
|
|
1031
|
+
},
|
|
1032
|
+
strategy_name=self.name,
|
|
1033
|
+
)
|
|
1034
|
+
self.logger.info(
|
|
1035
|
+
f"Recorded withdrawal of ${withdrawn_amount:.2f} in ledger"
|
|
1036
|
+
)
|
|
1037
|
+
except Exception as e:
|
|
1038
|
+
self.logger.warning(f"Failed to record withdrawal in ledger: {e}")
|
|
1039
|
+
|
|
1040
|
+
# Step 5: Wait a bit for the USDC to be credited on Arbitrum
|
|
1041
|
+
await asyncio.sleep(10)
|
|
1042
|
+
|
|
1043
|
+
# Get final USDC balance
|
|
1044
|
+
final_balance = 0.0
|
|
1045
|
+
try:
|
|
1046
|
+
success, balance_data = await self.balance_adapter.get_balance(
|
|
1047
|
+
token_id=usdc_token_id,
|
|
1048
|
+
wallet_address=address,
|
|
1049
|
+
)
|
|
1050
|
+
if success:
|
|
1051
|
+
final_balance = float(balance_data) / 1e6 # USDC has 6 decimals
|
|
1052
|
+
except Exception as e:
|
|
1053
|
+
self.logger.warning(f"Could not get final balance: {e}")
|
|
1054
|
+
|
|
1055
|
+
# Step 6: Send USDC from strategy wallet to main wallet
|
|
1056
|
+
amount_to_send = final_balance # Send full amount
|
|
1057
|
+
|
|
1058
|
+
if amount_to_send > 1.0 and main_address.lower() != address.lower():
|
|
1059
|
+
self.logger.info(
|
|
1060
|
+
f"Sending ${amount_to_send:.2f} USDC from strategy wallet to main wallet"
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
try:
|
|
1064
|
+
(
|
|
1065
|
+
send_success,
|
|
1066
|
+
send_result,
|
|
1067
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
1068
|
+
token_id=usdc_token_id,
|
|
1069
|
+
amount=amount_to_send,
|
|
1070
|
+
strategy_name=self.name,
|
|
1071
|
+
skip_ledger=False, # Record in ledger
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
if send_success:
|
|
1075
|
+
self.logger.info(
|
|
1076
|
+
f"Successfully sent ${amount_to_send:.2f} USDC to main wallet"
|
|
1077
|
+
)
|
|
1078
|
+
total_withdrawn += amount_to_send
|
|
1079
|
+
else:
|
|
1080
|
+
self.logger.warning(
|
|
1081
|
+
f"Failed to send USDC to main wallet: {send_result}"
|
|
1082
|
+
)
|
|
1083
|
+
return (
|
|
1084
|
+
True,
|
|
1085
|
+
f"Withdrew ${withdrawable:.2f} from Hyperliquid but failed to send to main wallet: {send_result}. "
|
|
1086
|
+
f"USDC is in strategy wallet ({address}).",
|
|
1087
|
+
)
|
|
1088
|
+
except Exception as e:
|
|
1089
|
+
self.logger.error(f"Error sending to main wallet: {e}")
|
|
1090
|
+
return (
|
|
1091
|
+
True,
|
|
1092
|
+
f"Withdrew ${withdrawable:.2f} from Hyperliquid but error sending to main wallet: {e}. "
|
|
1093
|
+
f"USDC is in strategy wallet ({address}).",
|
|
1094
|
+
)
|
|
1095
|
+
elif main_address.lower() == address.lower():
|
|
1096
|
+
self.logger.info("Main wallet is strategy wallet, no transfer needed")
|
|
1097
|
+
total_withdrawn += withdrawable
|
|
1098
|
+
else:
|
|
1099
|
+
self.logger.warning(f"Amount too small to transfer: ${amount_to_send:.2f}")
|
|
1100
|
+
|
|
1101
|
+
self.deposit_amount = 0
|
|
1102
|
+
self.current_position = None
|
|
1103
|
+
|
|
1104
|
+
return (
|
|
1105
|
+
True,
|
|
1106
|
+
f"Withdrew ${total_withdrawn:.2f} total to main wallet ({main_address}).",
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
async def _status(self) -> StatusDict:
|
|
1110
|
+
"""Return portfolio value and strategy status with live data."""
|
|
1111
|
+
total_value, hl_value, vault_value = await self._get_total_portfolio_value()
|
|
1112
|
+
|
|
1113
|
+
status_payload: dict[str, Any] = {
|
|
1114
|
+
"has_position": self.current_position is not None,
|
|
1115
|
+
"hyperliquid_value": hl_value,
|
|
1116
|
+
"vault_wallet_value": vault_value,
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if self.current_position is not None:
|
|
1120
|
+
status_payload.update(
|
|
1121
|
+
{
|
|
1122
|
+
"coin": self.current_position.coin,
|
|
1123
|
+
"spot_amount": self.current_position.spot_amount,
|
|
1124
|
+
"perp_amount": self.current_position.perp_amount,
|
|
1125
|
+
"entry_price": self.current_position.entry_price,
|
|
1126
|
+
"leverage": self.current_position.leverage,
|
|
1127
|
+
"funding_collected": self.current_position.funding_collected,
|
|
1128
|
+
}
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
# Get net deposit from ledger
|
|
1132
|
+
try:
|
|
1133
|
+
success, deposit_data = await self.ledger_adapter.get_strategy_net_deposit(
|
|
1134
|
+
wallet_address=self._get_strategy_wallet_address()
|
|
1135
|
+
)
|
|
1136
|
+
net_deposit = (
|
|
1137
|
+
float(deposit_data.get("net_deposit", 0) or 0)
|
|
1138
|
+
if success
|
|
1139
|
+
else self.deposit_amount
|
|
1140
|
+
)
|
|
1141
|
+
except Exception:
|
|
1142
|
+
net_deposit = self.deposit_amount
|
|
1143
|
+
|
|
1144
|
+
return StatusDict(
|
|
1145
|
+
portfolio_value=total_value,
|
|
1146
|
+
net_deposit=float(net_deposit),
|
|
1147
|
+
strategy_status=status_payload,
|
|
1148
|
+
gas_available=0.0,
|
|
1149
|
+
gassed_up=True,
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
@staticmethod
|
|
1153
|
+
async def policies() -> list[str]:
|
|
1154
|
+
"""Return wallet permission policies."""
|
|
1155
|
+
# Placeholder - would include Hyperliquid-specific policies
|
|
1156
|
+
return []
|
|
1157
|
+
|
|
1158
|
+
async def ensure_builder_fee_approved(self) -> StatusTuple:
|
|
1159
|
+
"""
|
|
1160
|
+
Ensure the builder fee is approved before trading.
|
|
1161
|
+
|
|
1162
|
+
Checks the current max builder fee approval for the user/builder pair.
|
|
1163
|
+
If the current approval is less than required, submits an approval transaction.
|
|
1164
|
+
|
|
1165
|
+
Returns:
|
|
1166
|
+
StatusTuple (success, message)
|
|
1167
|
+
"""
|
|
1168
|
+
if not self.builder_fee:
|
|
1169
|
+
return True, "No builder fee configured"
|
|
1170
|
+
|
|
1171
|
+
if self.simulation:
|
|
1172
|
+
return True, "[SIMULATION] Builder fee approval skipped"
|
|
1173
|
+
|
|
1174
|
+
address = self._get_strategy_wallet_address()
|
|
1175
|
+
builder = self.builder_fee.get("b", "")
|
|
1176
|
+
required_fee = self.builder_fee.get("f", 0)
|
|
1177
|
+
|
|
1178
|
+
if not builder or required_fee <= 0:
|
|
1179
|
+
return True, "Builder fee not required"
|
|
1180
|
+
|
|
1181
|
+
try:
|
|
1182
|
+
# Check current approval
|
|
1183
|
+
success, current_fee = await self.hyperliquid_adapter.get_max_builder_fee(
|
|
1184
|
+
user=address,
|
|
1185
|
+
builder=builder,
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
if not success:
|
|
1189
|
+
self.logger.warning(
|
|
1190
|
+
"Failed to check builder fee approval, continuing anyway"
|
|
1191
|
+
)
|
|
1192
|
+
return True, "Could not verify builder fee, proceeding"
|
|
1193
|
+
|
|
1194
|
+
self.logger.info(
|
|
1195
|
+
f"Builder fee approval check: current={current_fee}, required={required_fee}"
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
if current_fee >= required_fee:
|
|
1199
|
+
return True, f"Builder fee already approved: {current_fee}"
|
|
1200
|
+
|
|
1201
|
+
# Need to approve
|
|
1202
|
+
# Convert fee to percentage string (e.g., 30 tenths bp = 0.030%)
|
|
1203
|
+
max_fee_rate = f"{required_fee / 1000:.3f}%"
|
|
1204
|
+
self.logger.info(
|
|
1205
|
+
f"Approving builder fee: builder={builder}, rate={max_fee_rate}"
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
success, result = await self.hyperliquid_adapter.approve_builder_fee(
|
|
1209
|
+
builder=builder,
|
|
1210
|
+
max_fee_rate=max_fee_rate,
|
|
1211
|
+
address=address,
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
if not success:
|
|
1215
|
+
self.logger.error(f"Builder fee approval failed: {result}")
|
|
1216
|
+
return False, f"Builder fee approval failed: {result}"
|
|
1217
|
+
|
|
1218
|
+
self.logger.info(f"Builder fee approved: {result}")
|
|
1219
|
+
return True, f"Builder fee approved at {max_fee_rate}"
|
|
1220
|
+
|
|
1221
|
+
except Exception as e:
|
|
1222
|
+
self.logger.error(f"Builder fee approval error: {e}")
|
|
1223
|
+
return False, f"Builder fee approval error: {e}"
|
|
1224
|
+
|
|
1225
|
+
# ------------------------------------------------------------------ #
|
|
1226
|
+
# Position Management #
|
|
1227
|
+
# ------------------------------------------------------------------ #
|
|
1228
|
+
|
|
1229
|
+
async def _find_and_open_position(self) -> StatusTuple:
|
|
1230
|
+
"""Analyze markets and open the best basis position."""
|
|
1231
|
+
self.logger.info("Analyzing basis trading opportunities...")
|
|
1232
|
+
|
|
1233
|
+
try:
|
|
1234
|
+
best: dict[str, Any] | None = None
|
|
1235
|
+
|
|
1236
|
+
snapshot = self._snapshot_from_config()
|
|
1237
|
+
if snapshot is not None:
|
|
1238
|
+
try:
|
|
1239
|
+
opps = self.opportunities_from_snapshot(
|
|
1240
|
+
snapshot=snapshot, deposit_usdc=self.deposit_amount
|
|
1241
|
+
)
|
|
1242
|
+
if opps:
|
|
1243
|
+
self.logger.info(
|
|
1244
|
+
"Selecting best opportunity from in-memory batch snapshot"
|
|
1245
|
+
)
|
|
1246
|
+
best = await self.score_opportunity_from_snapshot(
|
|
1247
|
+
opportunity=opps[0],
|
|
1248
|
+
deposit_usdc=self.deposit_amount,
|
|
1249
|
+
horizons_days=[1, 7],
|
|
1250
|
+
stop_frac=self.LIQUIDATION_REBALANCE_THRESHOLD,
|
|
1251
|
+
lookback_days=self.DEFAULT_LOOKBACK_DAYS,
|
|
1252
|
+
fee_eps=self.DEFAULT_FEE_EPS,
|
|
1253
|
+
perp_slippage_bps=1.0,
|
|
1254
|
+
cooloff_hours=0,
|
|
1255
|
+
bootstrap_sims=int(
|
|
1256
|
+
self._cfg_get(
|
|
1257
|
+
"bootstrap_sims", self.DEFAULT_BOOTSTRAP_SIMS
|
|
1258
|
+
)
|
|
1259
|
+
or self.DEFAULT_BOOTSTRAP_SIMS
|
|
1260
|
+
),
|
|
1261
|
+
bootstrap_block_hours=int(
|
|
1262
|
+
self._cfg_get(
|
|
1263
|
+
"bootstrap_block_hours",
|
|
1264
|
+
self.DEFAULT_BOOTSTRAP_BLOCK_HOURS,
|
|
1265
|
+
)
|
|
1266
|
+
or 0
|
|
1267
|
+
),
|
|
1268
|
+
bootstrap_seed=self._cfg_get("bootstrap_seed"),
|
|
1269
|
+
)
|
|
1270
|
+
except Exception as exc: # noqa: BLE001
|
|
1271
|
+
self.logger.warning(
|
|
1272
|
+
f"Snapshot selection failed (in-memory): {exc}. Falling back to live solver."
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
snapshot_path = self._snapshot_path_from_config()
|
|
1276
|
+
if best is None and snapshot_path and Path(snapshot_path).exists():
|
|
1277
|
+
try:
|
|
1278
|
+
snapshot = self.load_snapshot_from_path(snapshot_path)
|
|
1279
|
+
opps = self.opportunities_from_snapshot(
|
|
1280
|
+
snapshot=snapshot, deposit_usdc=self.deposit_amount
|
|
1281
|
+
)
|
|
1282
|
+
if opps:
|
|
1283
|
+
self.logger.info(
|
|
1284
|
+
f"Selecting best opportunity from snapshot {snapshot_path}"
|
|
1285
|
+
)
|
|
1286
|
+
best = await self.score_opportunity_from_snapshot(
|
|
1287
|
+
opportunity=opps[0],
|
|
1288
|
+
deposit_usdc=self.deposit_amount,
|
|
1289
|
+
horizons_days=[1, 7],
|
|
1290
|
+
stop_frac=self.LIQUIDATION_REBALANCE_THRESHOLD,
|
|
1291
|
+
lookback_days=self.DEFAULT_LOOKBACK_DAYS,
|
|
1292
|
+
fee_eps=self.DEFAULT_FEE_EPS,
|
|
1293
|
+
perp_slippage_bps=1.0,
|
|
1294
|
+
cooloff_hours=0,
|
|
1295
|
+
bootstrap_sims=int(
|
|
1296
|
+
self._cfg_get(
|
|
1297
|
+
"bootstrap_sims", self.DEFAULT_BOOTSTRAP_SIMS
|
|
1298
|
+
)
|
|
1299
|
+
or self.DEFAULT_BOOTSTRAP_SIMS
|
|
1300
|
+
),
|
|
1301
|
+
bootstrap_block_hours=int(
|
|
1302
|
+
self._cfg_get(
|
|
1303
|
+
"bootstrap_block_hours",
|
|
1304
|
+
self.DEFAULT_BOOTSTRAP_BLOCK_HOURS,
|
|
1305
|
+
)
|
|
1306
|
+
or 0
|
|
1307
|
+
),
|
|
1308
|
+
bootstrap_seed=self._cfg_get("bootstrap_seed"),
|
|
1309
|
+
)
|
|
1310
|
+
except Exception as exc: # noqa: BLE001
|
|
1311
|
+
self.logger.warning(
|
|
1312
|
+
f"Snapshot selection failed ({snapshot_path}): {exc}. "
|
|
1313
|
+
"Falling back to live solver."
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
if best is None:
|
|
1317
|
+
best = await self.find_best_trade_with_backtest(
|
|
1318
|
+
deposit_usdc=self.deposit_amount,
|
|
1319
|
+
stop_frac=self.LIQUIDATION_REBALANCE_THRESHOLD,
|
|
1320
|
+
lookback_days=self.DEFAULT_LOOKBACK_DAYS,
|
|
1321
|
+
fee_eps=self.DEFAULT_FEE_EPS,
|
|
1322
|
+
oi_floor=self.DEFAULT_OI_FLOOR,
|
|
1323
|
+
day_vlm_floor=self.DEFAULT_DAY_VLM_FLOOR,
|
|
1324
|
+
horizons_days=[1, 7],
|
|
1325
|
+
max_leverage=self.DEFAULT_MAX_LEVERAGE,
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
if not best:
|
|
1329
|
+
return (True, "No suitable basis opportunities found at this time.")
|
|
1330
|
+
|
|
1331
|
+
coin = best.get("coin", "unknown")
|
|
1332
|
+
safe = best.get("safe", {})
|
|
1333
|
+
|
|
1334
|
+
# Use 7-day horizon sizing by default
|
|
1335
|
+
safe_7 = safe.get("7") or {}
|
|
1336
|
+
if safe_7.get("spot_usdc", 0) <= 0 or safe_7.get("perp_amount", 0) <= 0:
|
|
1337
|
+
return (True, f"Best opportunity ({coin}) returned zero sizing.")
|
|
1338
|
+
|
|
1339
|
+
leverage = int(safe_7.get("safe_leverage", best.get("best_L", 1)) or 1)
|
|
1340
|
+
expected_net_apy_pct = float(best.get("net_apy", 0.0) or 0.0) * 100.0
|
|
1341
|
+
target_qty = min(
|
|
1342
|
+
float(safe_7.get("spot_amount", 0.0) or 0.0),
|
|
1343
|
+
float(safe_7.get("perp_amount", 0.0) or 0.0),
|
|
1344
|
+
)
|
|
1345
|
+
spot_asset_id = best.get("spot_asset_id", 0)
|
|
1346
|
+
perp_asset_id = best.get("perp_asset_id", 0)
|
|
1347
|
+
|
|
1348
|
+
self.logger.info(
|
|
1349
|
+
f"Best opportunity: {coin} at {leverage}x leverage, "
|
|
1350
|
+
f"expected net APY: {expected_net_apy_pct:.2f}%, target qty: {target_qty}"
|
|
1351
|
+
)
|
|
1352
|
+
|
|
1353
|
+
# Execute position using PairedFiller
|
|
1354
|
+
address = self._get_strategy_wallet_address()
|
|
1355
|
+
order_usd = float(safe_7.get("spot_usdc", 0.0) or 0.0)
|
|
1356
|
+
order_usd = float(
|
|
1357
|
+
Decimal(str(order_usd)).quantize(Decimal("0.01"), rounding=ROUND_UP)
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
if self.simulation:
|
|
1361
|
+
self.logger.info(
|
|
1362
|
+
f"[SIMULATION] Would open {target_qty} {coin} basis position"
|
|
1363
|
+
)
|
|
1364
|
+
spot_filled = target_qty
|
|
1365
|
+
perp_filled = target_qty
|
|
1366
|
+
spot_notional = order_usd
|
|
1367
|
+
perp_notional = order_usd
|
|
1368
|
+
entry_price = float(best.get("mark_price", 0.0) or 100.0)
|
|
1369
|
+
else:
|
|
1370
|
+
# Step 1: Ensure builder fee is approved
|
|
1371
|
+
fee_success, fee_msg = await self.ensure_builder_fee_approved()
|
|
1372
|
+
if not fee_success:
|
|
1373
|
+
return (False, f"Builder fee approval failed: {fee_msg}")
|
|
1374
|
+
|
|
1375
|
+
# Step 2: Update leverage for the perp asset
|
|
1376
|
+
self.logger.info(f"Setting leverage to {leverage}x for {coin}")
|
|
1377
|
+
success, lev_result = await self.hyperliquid_adapter.update_leverage(
|
|
1378
|
+
asset_id=perp_asset_id,
|
|
1379
|
+
leverage=leverage,
|
|
1380
|
+
is_cross=True,
|
|
1381
|
+
address=address,
|
|
1382
|
+
)
|
|
1383
|
+
if not success:
|
|
1384
|
+
self.logger.warning(f"Failed to set leverage: {lev_result}")
|
|
1385
|
+
# Continue anyway - leverage might already be set
|
|
1386
|
+
|
|
1387
|
+
# Step 3: Transfer USDC from perp to spot for spot purchase
|
|
1388
|
+
# We need approximately order_usd in spot to buy the asset
|
|
1389
|
+
self.logger.info(
|
|
1390
|
+
f"Transferring ${order_usd:.2f} from perp to spot for {coin}"
|
|
1391
|
+
)
|
|
1392
|
+
(
|
|
1393
|
+
success,
|
|
1394
|
+
transfer_result,
|
|
1395
|
+
) = await self.hyperliquid_adapter.transfer_perp_to_spot(
|
|
1396
|
+
amount=order_usd,
|
|
1397
|
+
address=address,
|
|
1398
|
+
)
|
|
1399
|
+
if not success:
|
|
1400
|
+
self.logger.warning(
|
|
1401
|
+
f"Perp to spot transfer failed: {transfer_result}"
|
|
1402
|
+
)
|
|
1403
|
+
# May fail if already in spot, continue
|
|
1404
|
+
|
|
1405
|
+
# Step 4: Execute paired fill
|
|
1406
|
+
filler = PairedFiller(
|
|
1407
|
+
adapter=self.hyperliquid_adapter,
|
|
1408
|
+
address=address,
|
|
1409
|
+
cfg=FillConfig(max_slip_bps=35, max_chunk_usd=7500.0),
|
|
1410
|
+
)
|
|
1411
|
+
|
|
1412
|
+
(
|
|
1413
|
+
spot_filled,
|
|
1414
|
+
perp_filled,
|
|
1415
|
+
spot_notional,
|
|
1416
|
+
perp_notional,
|
|
1417
|
+
spot_pointers,
|
|
1418
|
+
perp_pointers,
|
|
1419
|
+
) = await filler.fill_pair_units(
|
|
1420
|
+
coin=coin,
|
|
1421
|
+
spot_asset_id=spot_asset_id,
|
|
1422
|
+
perp_asset_id=perp_asset_id,
|
|
1423
|
+
total_units=target_qty,
|
|
1424
|
+
direction="long_spot_short_perp",
|
|
1425
|
+
builder_fee=self.builder_fee,
|
|
1426
|
+
)
|
|
1427
|
+
|
|
1428
|
+
if spot_filled <= 0 or perp_filled <= 0:
|
|
1429
|
+
return (False, f"Failed to fill basis position on {coin}")
|
|
1430
|
+
|
|
1431
|
+
self.logger.info(
|
|
1432
|
+
f"Filled basis position: spot={spot_filled:.6f}, perp={perp_filled:.6f}, "
|
|
1433
|
+
f"notional=${spot_notional:.2f}/${perp_notional:.2f}"
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
# Get entry price from current mid
|
|
1437
|
+
success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
|
|
1438
|
+
entry_price = mids.get(coin, 0.0) if success else 0.0
|
|
1439
|
+
|
|
1440
|
+
# Step 5: Get liquidation price and place stop-loss
|
|
1441
|
+
success, user_state = await self.hyperliquid_adapter.get_user_state(
|
|
1442
|
+
address
|
|
1443
|
+
)
|
|
1444
|
+
liquidation_price = None
|
|
1445
|
+
if success:
|
|
1446
|
+
for pos_wrapper in user_state.get("assetPositions", []):
|
|
1447
|
+
pos = pos_wrapper.get("position", {})
|
|
1448
|
+
if pos.get("coin") == coin:
|
|
1449
|
+
liquidation_price = float(pos.get("liquidationPx", 0))
|
|
1450
|
+
break
|
|
1451
|
+
|
|
1452
|
+
if liquidation_price and liquidation_price > 0:
|
|
1453
|
+
sl_success, sl_msg = await self._place_stop_loss_orders(
|
|
1454
|
+
coin=coin,
|
|
1455
|
+
perp_asset_id=perp_asset_id,
|
|
1456
|
+
position_size=perp_filled,
|
|
1457
|
+
entry_price=entry_price,
|
|
1458
|
+
liquidation_price=liquidation_price,
|
|
1459
|
+
spot_asset_id=spot_asset_id,
|
|
1460
|
+
spot_position_size=spot_filled,
|
|
1461
|
+
)
|
|
1462
|
+
if not sl_success:
|
|
1463
|
+
self.logger.warning(f"Stop-loss placement failed: {sl_msg}")
|
|
1464
|
+
else:
|
|
1465
|
+
self.logger.warning("Could not get liquidation price for stop-loss")
|
|
1466
|
+
|
|
1467
|
+
# Create position record
|
|
1468
|
+
self.current_position = BasisPosition(
|
|
1469
|
+
coin=coin,
|
|
1470
|
+
spot_asset_id=spot_asset_id,
|
|
1471
|
+
perp_asset_id=perp_asset_id,
|
|
1472
|
+
spot_amount=spot_filled,
|
|
1473
|
+
perp_amount=perp_filled,
|
|
1474
|
+
entry_price=entry_price,
|
|
1475
|
+
leverage=leverage,
|
|
1476
|
+
entry_timestamp=int(time.time() * 1000),
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
return (
|
|
1480
|
+
True,
|
|
1481
|
+
f"Opened basis position on {coin}: {spot_filled:.4f} units at {leverage}x, expected net APY: {expected_net_apy_pct:.1f}%",
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
except Exception as e:
|
|
1485
|
+
self.logger.error(f"Error finding basis opportunities: {e}")
|
|
1486
|
+
return (False, f"Analysis failed: {e}")
|
|
1487
|
+
|
|
1488
|
+
# ------------------------------------------------------------------ #
|
|
1489
|
+
# Position Scaling #
|
|
1490
|
+
# ------------------------------------------------------------------ #
|
|
1491
|
+
|
|
1492
|
+
async def _get_undeployed_capital(self) -> tuple[float, float]:
|
|
1493
|
+
"""
|
|
1494
|
+
Calculate undeployed capital that can be added to the position.
|
|
1495
|
+
|
|
1496
|
+
Returns:
|
|
1497
|
+
(perp_margin_available, spot_usdc_available)
|
|
1498
|
+
"""
|
|
1499
|
+
address = self._get_strategy_wallet_address()
|
|
1500
|
+
|
|
1501
|
+
# Get perp state
|
|
1502
|
+
success, user_state = await self.hyperliquid_adapter.get_user_state(address)
|
|
1503
|
+
if not success:
|
|
1504
|
+
return 0.0, 0.0
|
|
1505
|
+
|
|
1506
|
+
# Hyperliquid userState commonly nests withdrawable under marginSummary, but keep
|
|
1507
|
+
# compatibility with any top-level "withdrawable" shape.
|
|
1508
|
+
withdrawable_val = user_state.get("withdrawable")
|
|
1509
|
+
if withdrawable_val is None:
|
|
1510
|
+
margin_summary = user_state.get("marginSummary") or {}
|
|
1511
|
+
if isinstance(margin_summary, dict):
|
|
1512
|
+
withdrawable_val = margin_summary.get("withdrawable")
|
|
1513
|
+
|
|
1514
|
+
withdrawable = float(withdrawable_val or 0.0)
|
|
1515
|
+
|
|
1516
|
+
# Get spot USDC balance
|
|
1517
|
+
success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
|
|
1518
|
+
address
|
|
1519
|
+
)
|
|
1520
|
+
spot_usdc = 0.0
|
|
1521
|
+
if success:
|
|
1522
|
+
for bal in spot_state.get("balances", []):
|
|
1523
|
+
if bal.get("coin") == "USDC":
|
|
1524
|
+
spot_usdc = float(bal.get("total", 0))
|
|
1525
|
+
break
|
|
1526
|
+
|
|
1527
|
+
return withdrawable, spot_usdc
|
|
1528
|
+
|
|
1529
|
+
async def _scale_up_position(self, additional_capital: float) -> StatusTuple:
|
|
1530
|
+
"""
|
|
1531
|
+
Add capital to existing position without breaking it.
|
|
1532
|
+
|
|
1533
|
+
Uses PairedFiller to atomically add to both spot and perp legs,
|
|
1534
|
+
maintaining delta neutrality.
|
|
1535
|
+
|
|
1536
|
+
Args:
|
|
1537
|
+
additional_capital: USD amount of new capital to deploy
|
|
1538
|
+
|
|
1539
|
+
Returns:
|
|
1540
|
+
StatusTuple (success, message)
|
|
1541
|
+
"""
|
|
1542
|
+
if self.current_position is None:
|
|
1543
|
+
return False, "No position to scale up"
|
|
1544
|
+
|
|
1545
|
+
pos = self.current_position
|
|
1546
|
+
address = self._get_strategy_wallet_address()
|
|
1547
|
+
|
|
1548
|
+
# Get current leverage from position
|
|
1549
|
+
leverage = pos.leverage or 2
|
|
1550
|
+
|
|
1551
|
+
# Calculate how much to add to each leg
|
|
1552
|
+
# order_usd = capital * (L / (L + 1)) for leveraged position
|
|
1553
|
+
order_usd = additional_capital * (leverage / (leverage + 1))
|
|
1554
|
+
|
|
1555
|
+
# Get current price
|
|
1556
|
+
success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
|
|
1557
|
+
if not success:
|
|
1558
|
+
return False, "Failed to get mid prices"
|
|
1559
|
+
|
|
1560
|
+
price = mids.get(pos.coin, 0.0)
|
|
1561
|
+
if price <= 0:
|
|
1562
|
+
return False, f"Invalid price for {pos.coin}"
|
|
1563
|
+
|
|
1564
|
+
# Check minimum notional ($10 USD per side)
|
|
1565
|
+
if order_usd < MIN_NOTIONAL_USD:
|
|
1566
|
+
return (
|
|
1567
|
+
True,
|
|
1568
|
+
f"Additional capital ${order_usd:.2f} below minimum notional ${MIN_NOTIONAL_USD}",
|
|
1569
|
+
)
|
|
1570
|
+
|
|
1571
|
+
# Calculate units to add
|
|
1572
|
+
units_to_add = order_usd / price
|
|
1573
|
+
|
|
1574
|
+
# Round to valid decimals for the assets
|
|
1575
|
+
spot_valid = self.hyperliquid_adapter.get_valid_order_size(
|
|
1576
|
+
pos.spot_asset_id, units_to_add
|
|
1577
|
+
)
|
|
1578
|
+
perp_valid = self.hyperliquid_adapter.get_valid_order_size(
|
|
1579
|
+
pos.perp_asset_id, units_to_add
|
|
1580
|
+
)
|
|
1581
|
+
units_to_add = min(spot_valid, perp_valid)
|
|
1582
|
+
|
|
1583
|
+
if units_to_add <= 0:
|
|
1584
|
+
return (
|
|
1585
|
+
True,
|
|
1586
|
+
"Additional capital rounds to zero units after decimal adjustment",
|
|
1587
|
+
)
|
|
1588
|
+
|
|
1589
|
+
self.logger.info(
|
|
1590
|
+
f"Scaling up {pos.coin} position: adding {units_to_add:.4f} units "
|
|
1591
|
+
f"(${order_usd:.2f}) at {leverage}x leverage"
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
# Transfer USDC from perp to spot for the spot purchase
|
|
1595
|
+
perp_margin, spot_usdc = await self._get_undeployed_capital()
|
|
1596
|
+
|
|
1597
|
+
if perp_margin > 1.0 and spot_usdc < order_usd:
|
|
1598
|
+
# Need to move some from perp margin to spot
|
|
1599
|
+
transfer_amount = min(perp_margin, order_usd - spot_usdc)
|
|
1600
|
+
success, result = await self.hyperliquid_adapter.transfer_perp_to_spot(
|
|
1601
|
+
amount=transfer_amount,
|
|
1602
|
+
address=address,
|
|
1603
|
+
)
|
|
1604
|
+
if not success:
|
|
1605
|
+
self.logger.warning(f"Perp to spot transfer failed: {result}")
|
|
1606
|
+
|
|
1607
|
+
# Execute paired fill to add to both legs
|
|
1608
|
+
filler = PairedFiller(
|
|
1609
|
+
adapter=self.hyperliquid_adapter,
|
|
1610
|
+
address=address,
|
|
1611
|
+
cfg=FillConfig(max_slip_bps=35, max_chunk_usd=7500.0),
|
|
1612
|
+
)
|
|
1613
|
+
|
|
1614
|
+
try:
|
|
1615
|
+
(
|
|
1616
|
+
spot_filled,
|
|
1617
|
+
perp_filled,
|
|
1618
|
+
spot_notional,
|
|
1619
|
+
perp_notional,
|
|
1620
|
+
_,
|
|
1621
|
+
_,
|
|
1622
|
+
) = await filler.fill_pair_units(
|
|
1623
|
+
coin=pos.coin,
|
|
1624
|
+
spot_asset_id=pos.spot_asset_id,
|
|
1625
|
+
perp_asset_id=pos.perp_asset_id,
|
|
1626
|
+
total_units=units_to_add,
|
|
1627
|
+
direction="long_spot_short_perp", # Buy spot, sell perp
|
|
1628
|
+
builder_fee=self.builder_fee,
|
|
1629
|
+
)
|
|
1630
|
+
except Exception as e:
|
|
1631
|
+
self.logger.error(f"PairedFiller failed: {e}")
|
|
1632
|
+
return False, f"Failed to scale position: {e}"
|
|
1633
|
+
|
|
1634
|
+
if spot_filled <= 0 or perp_filled <= 0:
|
|
1635
|
+
return False, f"Failed to add to position on {pos.coin}"
|
|
1636
|
+
|
|
1637
|
+
# Update position tracking
|
|
1638
|
+
self.current_position = BasisPosition(
|
|
1639
|
+
coin=pos.coin,
|
|
1640
|
+
spot_asset_id=pos.spot_asset_id,
|
|
1641
|
+
perp_asset_id=pos.perp_asset_id,
|
|
1642
|
+
spot_amount=pos.spot_amount + spot_filled,
|
|
1643
|
+
perp_amount=pos.perp_amount + perp_filled,
|
|
1644
|
+
entry_price=price, # Use new price as weighted entry
|
|
1645
|
+
leverage=leverage,
|
|
1646
|
+
entry_timestamp=pos.entry_timestamp, # Keep original
|
|
1647
|
+
funding_collected=pos.funding_collected,
|
|
1648
|
+
)
|
|
1649
|
+
|
|
1650
|
+
self.logger.info(
|
|
1651
|
+
f"Scaled up position: +{spot_filled:.4f} spot, +{perp_filled:.4f} perp. "
|
|
1652
|
+
f"Total now: {self.current_position.spot_amount:.4f} / {self.current_position.perp_amount:.4f}"
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
return (
|
|
1656
|
+
True,
|
|
1657
|
+
f"Added {spot_filled:.4f} {pos.coin} to position (${spot_notional:.2f})",
|
|
1658
|
+
)
|
|
1659
|
+
|
|
1660
|
+
async def _monitor_position(self) -> StatusTuple:
|
|
1661
|
+
"""
|
|
1662
|
+
Monitor existing position for exit/rebalance conditions.
|
|
1663
|
+
|
|
1664
|
+
Checks:
|
|
1665
|
+
1. Whether rebalance is needed (funding, liquidity, etc.)
|
|
1666
|
+
2. Both legs are balanced (spot and perp amounts match)
|
|
1667
|
+
3. No significant idle capital (deploy if found)
|
|
1668
|
+
4. Stop-loss orders are in place and valid
|
|
1669
|
+
"""
|
|
1670
|
+
if self.current_position is None:
|
|
1671
|
+
return (True, "No position to monitor")
|
|
1672
|
+
|
|
1673
|
+
pos = self.current_position
|
|
1674
|
+
coin = pos.coin
|
|
1675
|
+
address = self._get_strategy_wallet_address()
|
|
1676
|
+
actions_taken: list[str] = []
|
|
1677
|
+
|
|
1678
|
+
# Get current state
|
|
1679
|
+
success, state = await self.hyperliquid_adapter.get_user_state(address)
|
|
1680
|
+
if not success:
|
|
1681
|
+
return (False, f"Failed to fetch user state: {state}")
|
|
1682
|
+
|
|
1683
|
+
# Calculate deposited amount from current on-exchange value
|
|
1684
|
+
total_value, hl_value, _ = await self._get_total_portfolio_value()
|
|
1685
|
+
|
|
1686
|
+
# ------------------------------------------------------------------ #
|
|
1687
|
+
# Check 1: Rebalance needed? #
|
|
1688
|
+
# ------------------------------------------------------------------ #
|
|
1689
|
+
needs_rebalance, reason = await self._needs_new_position(state, hl_value)
|
|
1690
|
+
|
|
1691
|
+
if needs_rebalance:
|
|
1692
|
+
# Check rotation cooldown
|
|
1693
|
+
rotation_allowed, cooldown_reason = await self._is_rotation_allowed()
|
|
1694
|
+
|
|
1695
|
+
if not rotation_allowed:
|
|
1696
|
+
self.logger.info(f"Rebalance needed ({reason}) but {cooldown_reason}")
|
|
1697
|
+
return (
|
|
1698
|
+
True,
|
|
1699
|
+
f"Position needs attention but in cooldown: {cooldown_reason}",
|
|
1700
|
+
)
|
|
1701
|
+
|
|
1702
|
+
# Perform rebalance: close and reopen
|
|
1703
|
+
self.logger.info(f"Rebalancing position: {reason}")
|
|
1704
|
+
|
|
1705
|
+
# Close existing position
|
|
1706
|
+
close_success, close_msg = await self._close_position()
|
|
1707
|
+
if not close_success:
|
|
1708
|
+
return (False, f"Rebalance failed - could not close: {close_msg}")
|
|
1709
|
+
|
|
1710
|
+
# Open new position
|
|
1711
|
+
return await self._find_and_open_position()
|
|
1712
|
+
|
|
1713
|
+
# ------------------------------------------------------------------ #
|
|
1714
|
+
# Check 2: Verify both legs are balanced #
|
|
1715
|
+
# ------------------------------------------------------------------ #
|
|
1716
|
+
leg_ok, leg_msg = await self._verify_leg_balance(state)
|
|
1717
|
+
if not leg_ok:
|
|
1718
|
+
self.logger.warning(f"Leg imbalance detected: {leg_msg}")
|
|
1719
|
+
# Try to repair the imbalance
|
|
1720
|
+
repair_ok, repair_msg = await self._repair_leg_imbalance(state)
|
|
1721
|
+
if repair_ok:
|
|
1722
|
+
actions_taken.append(f"Repaired leg imbalance: {repair_msg}")
|
|
1723
|
+
else:
|
|
1724
|
+
actions_taken.append(f"Leg imbalance repair failed: {repair_msg}")
|
|
1725
|
+
|
|
1726
|
+
# ------------------------------------------------------------------ #
|
|
1727
|
+
# Check 3: Deploy any idle capital #
|
|
1728
|
+
# ------------------------------------------------------------------ #
|
|
1729
|
+
perp_margin, spot_usdc = await self._get_undeployed_capital()
|
|
1730
|
+
total_idle = perp_margin + spot_usdc
|
|
1731
|
+
min_deploy = max(self.MIN_UNUSED_USD, self.UNUSED_REL_EPS * self.deposit_amount)
|
|
1732
|
+
|
|
1733
|
+
if total_idle > min_deploy:
|
|
1734
|
+
self.logger.info(
|
|
1735
|
+
f"Found ${total_idle:.2f} idle capital, scaling up position"
|
|
1736
|
+
)
|
|
1737
|
+
scale_ok, scale_msg = await self._scale_up_position(total_idle)
|
|
1738
|
+
if scale_ok:
|
|
1739
|
+
actions_taken.append(f"Scaled up: {scale_msg}")
|
|
1740
|
+
# Refresh state after scale-up so stop-loss uses new position size/liq price
|
|
1741
|
+
success, state = await self.hyperliquid_adapter.get_user_state(address)
|
|
1742
|
+
if not success:
|
|
1743
|
+
self.logger.warning("Could not refresh state after scale-up")
|
|
1744
|
+
else:
|
|
1745
|
+
actions_taken.append(f"Scale-up failed: {scale_msg}")
|
|
1746
|
+
|
|
1747
|
+
# ------------------------------------------------------------------ #
|
|
1748
|
+
# Check 4: Verify stop-loss orders #
|
|
1749
|
+
# ------------------------------------------------------------------ #
|
|
1750
|
+
sl_ok, sl_msg = await self._ensure_stop_loss_valid(state)
|
|
1751
|
+
if not sl_ok:
|
|
1752
|
+
actions_taken.append(f"Stop-loss issue: {sl_msg}")
|
|
1753
|
+
elif "placed" in sl_msg.lower() or "updated" in sl_msg.lower():
|
|
1754
|
+
actions_taken.append(sl_msg)
|
|
1755
|
+
|
|
1756
|
+
position_age_hours = (time.time() * 1000 - pos.entry_timestamp) / (1000 * 3600)
|
|
1757
|
+
|
|
1758
|
+
if actions_taken:
|
|
1759
|
+
return (
|
|
1760
|
+
True,
|
|
1761
|
+
f"Position on {coin} monitored, age: {position_age_hours:.1f}h. Actions: {'; '.join(actions_taken)}",
|
|
1762
|
+
)
|
|
1763
|
+
|
|
1764
|
+
return (
|
|
1765
|
+
True,
|
|
1766
|
+
f"Position on {coin} healthy, age: {position_age_hours:.1f}h",
|
|
1767
|
+
)
|
|
1768
|
+
|
|
1769
|
+
async def _verify_leg_balance(self, state: dict[str, Any]) -> tuple[bool, str]:
|
|
1770
|
+
"""
|
|
1771
|
+
Verify that spot and perp legs are balanced (delta neutral).
|
|
1772
|
+
|
|
1773
|
+
Returns:
|
|
1774
|
+
(is_balanced, message)
|
|
1775
|
+
"""
|
|
1776
|
+
if self.current_position is None:
|
|
1777
|
+
return True, "No position"
|
|
1778
|
+
|
|
1779
|
+
pos = self.current_position
|
|
1780
|
+
coin = pos.coin
|
|
1781
|
+
|
|
1782
|
+
# Get actual perp position size from state
|
|
1783
|
+
perp_size = 0.0
|
|
1784
|
+
for pos_wrapper in state.get("assetPositions", []):
|
|
1785
|
+
position = pos_wrapper.get("position", {})
|
|
1786
|
+
if position.get("coin") == coin:
|
|
1787
|
+
perp_size = abs(float(position.get("szi", 0)))
|
|
1788
|
+
break
|
|
1789
|
+
|
|
1790
|
+
# Get actual spot balance
|
|
1791
|
+
address = self._get_strategy_wallet_address()
|
|
1792
|
+
success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
|
|
1793
|
+
address
|
|
1794
|
+
)
|
|
1795
|
+
spot_size = 0.0
|
|
1796
|
+
if success:
|
|
1797
|
+
for bal in spot_state.get("balances", []):
|
|
1798
|
+
if bal.get("coin") == coin:
|
|
1799
|
+
spot_size = float(bal.get("total", 0))
|
|
1800
|
+
break
|
|
1801
|
+
|
|
1802
|
+
# Check balance - allow 2% tolerance
|
|
1803
|
+
if spot_size <= 0 and perp_size <= 0:
|
|
1804
|
+
return False, "Both legs are zero"
|
|
1805
|
+
|
|
1806
|
+
max_size = max(spot_size, perp_size)
|
|
1807
|
+
if max_size > 0:
|
|
1808
|
+
imbalance_pct = abs(spot_size - perp_size) / max_size
|
|
1809
|
+
if imbalance_pct > 0.02: # 2% tolerance
|
|
1810
|
+
return (
|
|
1811
|
+
False,
|
|
1812
|
+
f"Imbalance: spot={spot_size:.6f}, perp={perp_size:.6f} ({imbalance_pct * 100:.1f}%)",
|
|
1813
|
+
)
|
|
1814
|
+
|
|
1815
|
+
# Update tracked position with actual values
|
|
1816
|
+
self.current_position = BasisPosition(
|
|
1817
|
+
coin=pos.coin,
|
|
1818
|
+
spot_asset_id=pos.spot_asset_id,
|
|
1819
|
+
perp_asset_id=pos.perp_asset_id,
|
|
1820
|
+
spot_amount=spot_size,
|
|
1821
|
+
perp_amount=perp_size,
|
|
1822
|
+
entry_price=pos.entry_price,
|
|
1823
|
+
leverage=pos.leverage,
|
|
1824
|
+
entry_timestamp=pos.entry_timestamp,
|
|
1825
|
+
funding_collected=pos.funding_collected,
|
|
1826
|
+
)
|
|
1827
|
+
|
|
1828
|
+
return True, f"Balanced: spot={spot_size:.6f}, perp={perp_size:.6f}"
|
|
1829
|
+
|
|
1830
|
+
async def _repair_leg_imbalance(self, state: dict[str, Any]) -> tuple[bool, str]:
|
|
1831
|
+
"""
|
|
1832
|
+
Attempt to repair an imbalance between spot and perp legs.
|
|
1833
|
+
|
|
1834
|
+
If one leg is larger, adds to the smaller leg to match.
|
|
1835
|
+
"""
|
|
1836
|
+
if self.current_position is None:
|
|
1837
|
+
return True, "No position"
|
|
1838
|
+
|
|
1839
|
+
if self.simulation:
|
|
1840
|
+
return True, "[SIMULATION] Would repair leg imbalance"
|
|
1841
|
+
|
|
1842
|
+
pos = self.current_position
|
|
1843
|
+
coin = pos.coin
|
|
1844
|
+
address = self._get_strategy_wallet_address()
|
|
1845
|
+
|
|
1846
|
+
# Get actual sizes
|
|
1847
|
+
perp_size = 0.0
|
|
1848
|
+
for pos_wrapper in state.get("assetPositions", []):
|
|
1849
|
+
position = pos_wrapper.get("position", {})
|
|
1850
|
+
if position.get("coin") == coin:
|
|
1851
|
+
perp_size = abs(float(position.get("szi", 0)))
|
|
1852
|
+
break
|
|
1853
|
+
|
|
1854
|
+
success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
|
|
1855
|
+
address
|
|
1856
|
+
)
|
|
1857
|
+
spot_size = 0.0
|
|
1858
|
+
if success:
|
|
1859
|
+
for bal in spot_state.get("balances", []):
|
|
1860
|
+
if bal.get("coin") == coin:
|
|
1861
|
+
spot_size = float(bal.get("total", 0))
|
|
1862
|
+
break
|
|
1863
|
+
|
|
1864
|
+
diff = abs(spot_size - perp_size)
|
|
1865
|
+
if diff < 0.001:
|
|
1866
|
+
return True, "Legs already balanced"
|
|
1867
|
+
|
|
1868
|
+
# Get current price
|
|
1869
|
+
success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
|
|
1870
|
+
if not success:
|
|
1871
|
+
return False, "Failed to get mid prices"
|
|
1872
|
+
price = mids.get(coin, 0)
|
|
1873
|
+
if price <= 0:
|
|
1874
|
+
return False, f"Invalid price for {coin}"
|
|
1875
|
+
|
|
1876
|
+
diff_usd = diff * price
|
|
1877
|
+
if diff_usd < 10: # Below minimum notional
|
|
1878
|
+
return True, f"Imbalance ${diff_usd:.2f} below minimum notional"
|
|
1879
|
+
|
|
1880
|
+
try:
|
|
1881
|
+
if spot_size > perp_size:
|
|
1882
|
+
# Need more perp (short more)
|
|
1883
|
+
self.logger.info(
|
|
1884
|
+
f"Repairing imbalance: shorting {diff:.6f} {coin} perp"
|
|
1885
|
+
)
|
|
1886
|
+
success, result = await self.hyperliquid_adapter.place_market_order(
|
|
1887
|
+
asset_id=pos.perp_asset_id,
|
|
1888
|
+
is_buy=False,
|
|
1889
|
+
slippage=0.01,
|
|
1890
|
+
size=self.hyperliquid_adapter.get_valid_order_size(
|
|
1891
|
+
pos.perp_asset_id, diff
|
|
1892
|
+
),
|
|
1893
|
+
address=address,
|
|
1894
|
+
builder=self.builder_fee,
|
|
1895
|
+
)
|
|
1896
|
+
if not success:
|
|
1897
|
+
return False, f"Failed to add perp: {result}"
|
|
1898
|
+
return True, f"Added {diff:.6f} perp short"
|
|
1899
|
+
else:
|
|
1900
|
+
# Need more spot (buy more)
|
|
1901
|
+
self.logger.info(f"Repairing imbalance: buying {diff:.6f} {coin} spot")
|
|
1902
|
+
success, result = await self.hyperliquid_adapter.place_market_order(
|
|
1903
|
+
asset_id=pos.spot_asset_id,
|
|
1904
|
+
is_buy=True,
|
|
1905
|
+
slippage=0.01,
|
|
1906
|
+
size=self.hyperliquid_adapter.get_valid_order_size(
|
|
1907
|
+
pos.spot_asset_id, diff
|
|
1908
|
+
),
|
|
1909
|
+
address=address,
|
|
1910
|
+
builder=self.builder_fee,
|
|
1911
|
+
)
|
|
1912
|
+
if not success:
|
|
1913
|
+
return False, f"Failed to add spot: {result}"
|
|
1914
|
+
return True, f"Added {diff:.6f} spot"
|
|
1915
|
+
except Exception as e:
|
|
1916
|
+
return False, f"Repair failed: {e}"
|
|
1917
|
+
|
|
1918
|
+
async def _ensure_stop_loss_valid(self, state: dict[str, Any]) -> tuple[bool, str]:
|
|
1919
|
+
"""
|
|
1920
|
+
Ensure stop-loss orders are in place and valid for current position.
|
|
1921
|
+
|
|
1922
|
+
Checks:
|
|
1923
|
+
- Stop-loss exists for the perp leg
|
|
1924
|
+
- Trigger price is valid (below liquidation price)
|
|
1925
|
+
- Size matches position size
|
|
1926
|
+
|
|
1927
|
+
Returns:
|
|
1928
|
+
(success, message)
|
|
1929
|
+
"""
|
|
1930
|
+
if self.current_position is None:
|
|
1931
|
+
return True, "No position"
|
|
1932
|
+
|
|
1933
|
+
if self.simulation:
|
|
1934
|
+
return True, "[SIMULATION] Stop-loss check skipped"
|
|
1935
|
+
|
|
1936
|
+
pos = self.current_position
|
|
1937
|
+
coin = pos.coin
|
|
1938
|
+
|
|
1939
|
+
# Get current perp position and liquidation price
|
|
1940
|
+
perp_size = 0.0
|
|
1941
|
+
liquidation_price = None
|
|
1942
|
+
entry_price = pos.entry_price
|
|
1943
|
+
|
|
1944
|
+
for pos_wrapper in state.get("assetPositions", []):
|
|
1945
|
+
position = pos_wrapper.get("position", {})
|
|
1946
|
+
if position.get("coin") == coin:
|
|
1947
|
+
perp_size = abs(float(position.get("szi", 0)))
|
|
1948
|
+
liquidation_price = float(position.get("liquidationPx", 0))
|
|
1949
|
+
# Update entry price from position if available
|
|
1950
|
+
entry_px = position.get("entryPx")
|
|
1951
|
+
if entry_px:
|
|
1952
|
+
entry_price = float(entry_px)
|
|
1953
|
+
break
|
|
1954
|
+
|
|
1955
|
+
if perp_size <= 0:
|
|
1956
|
+
return True, "No perp position to protect"
|
|
1957
|
+
|
|
1958
|
+
if not liquidation_price or liquidation_price <= 0:
|
|
1959
|
+
return False, "Could not determine liquidation price"
|
|
1960
|
+
|
|
1961
|
+
# Get spot position size from LIVE balance (not stored position)
|
|
1962
|
+
# to ensure stop-loss covers the actual spot holdings
|
|
1963
|
+
spot_position = await self._get_spot_position()
|
|
1964
|
+
if spot_position:
|
|
1965
|
+
spot_size = float(spot_position.get("total", 0))
|
|
1966
|
+
else:
|
|
1967
|
+
spot_size = pos.spot_amount
|
|
1968
|
+
|
|
1969
|
+
# Call existing method which checks and places/updates if needed
|
|
1970
|
+
return await self._place_stop_loss_orders(
|
|
1971
|
+
coin=coin,
|
|
1972
|
+
perp_asset_id=pos.perp_asset_id,
|
|
1973
|
+
position_size=perp_size,
|
|
1974
|
+
entry_price=entry_price,
|
|
1975
|
+
liquidation_price=liquidation_price,
|
|
1976
|
+
spot_asset_id=pos.spot_asset_id,
|
|
1977
|
+
spot_position_size=spot_size,
|
|
1978
|
+
)
|
|
1979
|
+
|
|
1980
|
+
async def _cancel_all_position_orders(self) -> None:
|
|
1981
|
+
"""Cancel all open orders (stop-loss, limit) for the current position."""
|
|
1982
|
+
if self.current_position is None:
|
|
1983
|
+
return
|
|
1984
|
+
|
|
1985
|
+
pos = self.current_position
|
|
1986
|
+
address = self._get_strategy_wallet_address()
|
|
1987
|
+
spot_coin = (
|
|
1988
|
+
f"@{pos.spot_asset_id - 10000}" if pos.spot_asset_id >= 10000 else None
|
|
1989
|
+
)
|
|
1990
|
+
|
|
1991
|
+
# Get all open orders including triggers
|
|
1992
|
+
success, open_orders = await self.hyperliquid_adapter.get_frontend_open_orders(
|
|
1993
|
+
address
|
|
1994
|
+
)
|
|
1995
|
+
if not success:
|
|
1996
|
+
self.logger.warning("Could not fetch open orders to cancel")
|
|
1997
|
+
return
|
|
1998
|
+
|
|
1999
|
+
for order in open_orders:
|
|
2000
|
+
order_coin = order.get("coin", "")
|
|
2001
|
+
order_id = order.get("oid")
|
|
2002
|
+
|
|
2003
|
+
# Cancel perp orders for this coin
|
|
2004
|
+
if order_coin == pos.coin and order_id:
|
|
2005
|
+
self.logger.info(f"Canceling perp order {order_id} for {pos.coin}")
|
|
2006
|
+
await self.hyperliquid_adapter.cancel_order(
|
|
2007
|
+
asset_id=pos.perp_asset_id,
|
|
2008
|
+
order_id=order_id,
|
|
2009
|
+
address=address,
|
|
2010
|
+
)
|
|
2011
|
+
|
|
2012
|
+
# Cancel spot orders for this coin
|
|
2013
|
+
if spot_coin and order_coin == spot_coin and order_id:
|
|
2014
|
+
self.logger.info(f"Canceling spot order {order_id} for {spot_coin}")
|
|
2015
|
+
await self.hyperliquid_adapter.cancel_order(
|
|
2016
|
+
asset_id=pos.spot_asset_id,
|
|
2017
|
+
order_id=order_id,
|
|
2018
|
+
address=address,
|
|
2019
|
+
)
|
|
2020
|
+
|
|
2021
|
+
async def _close_position(self) -> StatusTuple:
|
|
2022
|
+
"""Close the current position."""
|
|
2023
|
+
if self.current_position is None:
|
|
2024
|
+
return (True, "No position to close")
|
|
2025
|
+
|
|
2026
|
+
pos = self.current_position
|
|
2027
|
+
self.logger.info(f"Closing position on {pos.coin}")
|
|
2028
|
+
|
|
2029
|
+
if self.simulation:
|
|
2030
|
+
self.logger.info(
|
|
2031
|
+
f"[SIMULATION] Would close {pos.spot_amount} {pos.coin} basis position"
|
|
2032
|
+
)
|
|
2033
|
+
self.current_position = None
|
|
2034
|
+
return (True, "Position closed (simulation)")
|
|
2035
|
+
|
|
2036
|
+
# Cancel all stop-loss and limit orders first
|
|
2037
|
+
await self._cancel_all_position_orders()
|
|
2038
|
+
|
|
2039
|
+
# Real execution via PairedFiller - reverse direction to close
|
|
2040
|
+
try:
|
|
2041
|
+
address = self._get_strategy_wallet_address()
|
|
2042
|
+
filler = PairedFiller(
|
|
2043
|
+
adapter=self.hyperliquid_adapter,
|
|
2044
|
+
address=address,
|
|
2045
|
+
cfg=FillConfig(max_slip_bps=50, max_chunk_usd=7500.0),
|
|
2046
|
+
)
|
|
2047
|
+
|
|
2048
|
+
# Close by going opposite direction: sell spot, buy perp
|
|
2049
|
+
close_units = max(pos.spot_amount, pos.perp_amount)
|
|
2050
|
+
(
|
|
2051
|
+
spot_closed,
|
|
2052
|
+
perp_closed,
|
|
2053
|
+
spot_notional,
|
|
2054
|
+
perp_notional,
|
|
2055
|
+
_,
|
|
2056
|
+
_,
|
|
2057
|
+
) = await filler.fill_pair_units(
|
|
2058
|
+
coin=pos.coin,
|
|
2059
|
+
spot_asset_id=pos.spot_asset_id,
|
|
2060
|
+
perp_asset_id=pos.perp_asset_id,
|
|
2061
|
+
total_units=close_units,
|
|
2062
|
+
direction="short_spot_long_perp", # Reverse to close
|
|
2063
|
+
builder_fee=self.builder_fee,
|
|
2064
|
+
)
|
|
2065
|
+
|
|
2066
|
+
if spot_closed <= 0 and perp_closed <= 0:
|
|
2067
|
+
self.logger.warning(
|
|
2068
|
+
f"Position close may be incomplete: spot={spot_closed}, perp={perp_closed}"
|
|
2069
|
+
)
|
|
2070
|
+
|
|
2071
|
+
self.logger.info(
|
|
2072
|
+
f"Closed position: spot={spot_closed:.6f}, perp={perp_closed:.6f}"
|
|
2073
|
+
)
|
|
2074
|
+
self.current_position = None
|
|
2075
|
+
return (True, f"Closed position on {pos.coin}")
|
|
2076
|
+
|
|
2077
|
+
except Exception as e:
|
|
2078
|
+
self.logger.error(f"Error closing position: {e}")
|
|
2079
|
+
return (False, f"Failed to close position: {e}")
|
|
2080
|
+
|
|
2081
|
+
# ------------------------------------------------------------------ #
|
|
2082
|
+
# Position Health Checks #
|
|
2083
|
+
# ------------------------------------------------------------------ #
|
|
2084
|
+
|
|
2085
|
+
async def _needs_new_position(
|
|
2086
|
+
self,
|
|
2087
|
+
state: dict[str, Any],
|
|
2088
|
+
deposited_amount: float,
|
|
2089
|
+
best: dict[str, Any] | None = None,
|
|
2090
|
+
) -> tuple[bool, str]:
|
|
2091
|
+
"""
|
|
2092
|
+
Check if current delta-neutral position needs rebalancing.
|
|
2093
|
+
|
|
2094
|
+
Implements 7 health checks from Django hyperliquid_adapter.py:
|
|
2095
|
+
1. Missing positions
|
|
2096
|
+
2. Asset mismatch (if best specified)
|
|
2097
|
+
3. Funding accumulation threshold
|
|
2098
|
+
4. Perp must be SHORT
|
|
2099
|
+
5. Position imbalance (±4% dust tolerance)
|
|
2100
|
+
6. Unused bankroll
|
|
2101
|
+
7. Stop-loss orders exist and are valid
|
|
2102
|
+
|
|
2103
|
+
Returns:
|
|
2104
|
+
(needs_rebalance, reason) - True if rebalance needed
|
|
2105
|
+
"""
|
|
2106
|
+
perp_position = self._get_perp_position(state)
|
|
2107
|
+
spot_position = await self._get_spot_position()
|
|
2108
|
+
|
|
2109
|
+
# Check 1: Missing positions
|
|
2110
|
+
if perp_position is None or spot_position is None:
|
|
2111
|
+
return True, "Missing perp or spot position"
|
|
2112
|
+
|
|
2113
|
+
# Check 2: Asset mismatch (if best specified)
|
|
2114
|
+
if best:
|
|
2115
|
+
if perp_position.get("asset_id") != best.get("perp_asset_id"):
|
|
2116
|
+
return True, "Perp asset mismatch"
|
|
2117
|
+
if spot_position.get("asset_id") != best.get("spot_asset_id"):
|
|
2118
|
+
return True, "Spot asset mismatch"
|
|
2119
|
+
|
|
2120
|
+
# Check 3: Funding accumulation threshold
|
|
2121
|
+
funding_earned = self._get_funding_earned(state)
|
|
2122
|
+
if funding_earned > deposited_amount * self.FUNDING_REBALANCE_THRESHOLD:
|
|
2123
|
+
return True, f"Funding earned {funding_earned:.2f} exceeds threshold"
|
|
2124
|
+
|
|
2125
|
+
# Check 4: Perp must be SHORT
|
|
2126
|
+
perp_size = float(perp_position.get("szi", 0))
|
|
2127
|
+
if perp_size >= 0:
|
|
2128
|
+
return True, "Perp position is not short"
|
|
2129
|
+
|
|
2130
|
+
# Check 5: Position imbalance (±4% dust tolerance)
|
|
2131
|
+
spot_size = abs(float(spot_position.get("total", 0)))
|
|
2132
|
+
perp_size_abs = abs(perp_size)
|
|
2133
|
+
lower = spot_size * (1 - self.SPOT_POSITION_DUST_TOLERANCE)
|
|
2134
|
+
upper = spot_size * (1 + self.SPOT_POSITION_DUST_TOLERANCE)
|
|
2135
|
+
if not (lower <= perp_size_abs <= upper):
|
|
2136
|
+
return True, f"Position imbalance: spot={spot_size}, perp={perp_size_abs}"
|
|
2137
|
+
|
|
2138
|
+
# Note: Unused capital is handled by _scale_up_position() in _monitor_position's
|
|
2139
|
+
# Check 3, NOT here. We should never trigger a full rebalance just because
|
|
2140
|
+
# there's idle capital - that should be added to the existing position.
|
|
2141
|
+
|
|
2142
|
+
# Note: Stop-loss validation is handled separately in _monitor_position's
|
|
2143
|
+
# Check 4 (_ensure_stop_loss_valid) which will place/update orders as needed
|
|
2144
|
+
# without triggering a full rebalance.
|
|
2145
|
+
|
|
2146
|
+
return False, "Position healthy"
|
|
2147
|
+
|
|
2148
|
+
def _get_perp_position(self, state: dict[str, Any]) -> dict[str, Any] | None:
|
|
2149
|
+
"""Extract perp position matching current position from user state."""
|
|
2150
|
+
if self.current_position is None:
|
|
2151
|
+
return None
|
|
2152
|
+
|
|
2153
|
+
asset_positions = state.get("assetPositions", [])
|
|
2154
|
+
for pos_wrapper in asset_positions:
|
|
2155
|
+
pos = pos_wrapper.get("position", {})
|
|
2156
|
+
coin = pos.get("coin")
|
|
2157
|
+
if coin == self.current_position.coin:
|
|
2158
|
+
pos["asset_id"] = self.current_position.perp_asset_id
|
|
2159
|
+
return pos
|
|
2160
|
+
|
|
2161
|
+
return None
|
|
2162
|
+
|
|
2163
|
+
async def _get_spot_position(self) -> dict[str, Any] | None:
|
|
2164
|
+
"""Get spot position from spot user state."""
|
|
2165
|
+
if self.current_position is None:
|
|
2166
|
+
return None
|
|
2167
|
+
|
|
2168
|
+
address = self._get_strategy_wallet_address()
|
|
2169
|
+
success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
|
|
2170
|
+
address
|
|
2171
|
+
)
|
|
2172
|
+
if not success:
|
|
2173
|
+
return None
|
|
2174
|
+
|
|
2175
|
+
balances = spot_state.get("balances", [])
|
|
2176
|
+
for bal in balances:
|
|
2177
|
+
coin = bal.get("coin", "")
|
|
2178
|
+
# Match by stripping /USDC suffix or checking against coin name
|
|
2179
|
+
if coin == self.current_position.coin or coin.startswith(
|
|
2180
|
+
self.current_position.coin
|
|
2181
|
+
):
|
|
2182
|
+
bal["asset_id"] = self.current_position.spot_asset_id
|
|
2183
|
+
return bal
|
|
2184
|
+
|
|
2185
|
+
return None
|
|
2186
|
+
|
|
2187
|
+
def _get_funding_earned(self, state: dict[str, Any]) -> float:
|
|
2188
|
+
"""Extract cumulative funding earned from user state."""
|
|
2189
|
+
if self.current_position is None:
|
|
2190
|
+
return 0.0
|
|
2191
|
+
|
|
2192
|
+
asset_positions = state.get("assetPositions", [])
|
|
2193
|
+
for pos_wrapper in asset_positions:
|
|
2194
|
+
pos = pos_wrapper.get("position", {})
|
|
2195
|
+
if pos.get("coin") == self.current_position.coin:
|
|
2196
|
+
return abs(float(pos.get("cumFunding", {}).get("sinceOpen", 0)))
|
|
2197
|
+
|
|
2198
|
+
return 0.0
|
|
2199
|
+
|
|
2200
|
+
def _calculate_unused_usd(
|
|
2201
|
+
self, state: dict[str, Any], deposited_amount: float
|
|
2202
|
+
) -> float:
|
|
2203
|
+
"""Calculate unused USD not deployed in positions."""
|
|
2204
|
+
# Get account value
|
|
2205
|
+
margin_summary = state.get("marginSummary", {})
|
|
2206
|
+
account_value = float(margin_summary.get("accountValue", 0))
|
|
2207
|
+
|
|
2208
|
+
# Get total position value
|
|
2209
|
+
total_ntl = float(margin_summary.get("totalNtlPos", 0))
|
|
2210
|
+
|
|
2211
|
+
# Unused = account value - position value
|
|
2212
|
+
# For basis trading, we want most capital deployed
|
|
2213
|
+
unused = account_value - abs(total_ntl)
|
|
2214
|
+
return max(0.0, unused)
|
|
2215
|
+
|
|
2216
|
+
async def _validate_stop_loss_orders(
|
|
2217
|
+
self,
|
|
2218
|
+
state: dict[str, Any],
|
|
2219
|
+
perp_position: dict[str, Any],
|
|
2220
|
+
) -> tuple[bool, str]:
|
|
2221
|
+
"""Validate stop-loss orders exist and are below liquidation price."""
|
|
2222
|
+
address = self._get_strategy_wallet_address()
|
|
2223
|
+
|
|
2224
|
+
# Get liquidation price from perp position
|
|
2225
|
+
liquidation_price = perp_position.get("liquidationPx")
|
|
2226
|
+
if liquidation_price is None:
|
|
2227
|
+
return False, "No liquidation price found"
|
|
2228
|
+
liquidation_price = float(liquidation_price)
|
|
2229
|
+
|
|
2230
|
+
# Get open orders
|
|
2231
|
+
success, open_orders = await self.hyperliquid_adapter.get_open_orders(address)
|
|
2232
|
+
if not success:
|
|
2233
|
+
return False, "Failed to fetch open orders"
|
|
2234
|
+
|
|
2235
|
+
perp_asset_id = perp_position.get("asset_id")
|
|
2236
|
+
|
|
2237
|
+
# Find stop-loss orders for perp
|
|
2238
|
+
perp_sl_order = None
|
|
2239
|
+
|
|
2240
|
+
for order in open_orders:
|
|
2241
|
+
order_type = order.get("orderType", "")
|
|
2242
|
+
if "trigger" not in str(order_type).lower():
|
|
2243
|
+
continue
|
|
2244
|
+
|
|
2245
|
+
asset = order.get("coin") or order.get("asset")
|
|
2246
|
+
coin_match = (
|
|
2247
|
+
asset == self.current_position.coin if self.current_position else False
|
|
2248
|
+
)
|
|
2249
|
+
|
|
2250
|
+
if coin_match or order.get("asset_id") == perp_asset_id:
|
|
2251
|
+
perp_sl_order = order
|
|
2252
|
+
break
|
|
2253
|
+
|
|
2254
|
+
# Validate perp stop-loss exists
|
|
2255
|
+
if perp_sl_order is None:
|
|
2256
|
+
return False, "Missing perp stop-loss order"
|
|
2257
|
+
|
|
2258
|
+
# Validate price is below liquidation (for short, SL triggers on price RISE)
|
|
2259
|
+
perp_sl_price = float(perp_sl_order.get("triggerPx", 0))
|
|
2260
|
+
if perp_sl_price >= liquidation_price:
|
|
2261
|
+
return (
|
|
2262
|
+
False,
|
|
2263
|
+
f"Perp stop-loss {perp_sl_price} >= liquidation {liquidation_price}",
|
|
2264
|
+
)
|
|
2265
|
+
|
|
2266
|
+
return True, "Stop-loss orders valid"
|
|
2267
|
+
|
|
2268
|
+
async def _place_stop_loss_orders(
|
|
2269
|
+
self,
|
|
2270
|
+
coin: str,
|
|
2271
|
+
perp_asset_id: int,
|
|
2272
|
+
position_size: float,
|
|
2273
|
+
entry_price: float,
|
|
2274
|
+
liquidation_price: float,
|
|
2275
|
+
spot_asset_id: int | None = None,
|
|
2276
|
+
spot_position_size: float | None = None,
|
|
2277
|
+
) -> tuple[bool, str]:
|
|
2278
|
+
"""
|
|
2279
|
+
Place stop-loss orders for both perp and spot legs.
|
|
2280
|
+
|
|
2281
|
+
For basis trading:
|
|
2282
|
+
- Perp leg: Stop-market trigger order (buy to close short when price rises)
|
|
2283
|
+
- Spot leg: Limit sell order (sell spot at stop-loss price)
|
|
2284
|
+
|
|
2285
|
+
Both orders together maintain delta neutrality when the stop-loss is hit.
|
|
2286
|
+
"""
|
|
2287
|
+
address = self._get_strategy_wallet_address()
|
|
2288
|
+
|
|
2289
|
+
# Get spot info from current position if not provided
|
|
2290
|
+
if spot_asset_id is None or spot_position_size is None:
|
|
2291
|
+
if self.current_position:
|
|
2292
|
+
spot_asset_id = self.current_position.spot_asset_id
|
|
2293
|
+
spot_position_size = self.current_position.spot_amount
|
|
2294
|
+
else:
|
|
2295
|
+
spot_asset_id = None
|
|
2296
|
+
spot_position_size = 0.0
|
|
2297
|
+
|
|
2298
|
+
# Calculate stop-loss trigger price (90% of distance to liquidation)
|
|
2299
|
+
# For short perp, liquidation is ABOVE entry price
|
|
2300
|
+
stop_loss_price = (
|
|
2301
|
+
entry_price
|
|
2302
|
+
+ (liquidation_price - entry_price) * self.LIQUIDATION_STOP_LOSS_THRESHOLD
|
|
2303
|
+
)
|
|
2304
|
+
# Round to 5 significant figures to avoid SDK float_to_wire precision errors
|
|
2305
|
+
stop_loss_price = float(f"{stop_loss_price:.5g}")
|
|
2306
|
+
|
|
2307
|
+
# Get all open orders (frontend_open_orders includes trigger orders)
|
|
2308
|
+
success, open_orders = await self.hyperliquid_adapter.get_frontend_open_orders(
|
|
2309
|
+
address
|
|
2310
|
+
)
|
|
2311
|
+
|
|
2312
|
+
# Track existing valid orders and orders to cancel
|
|
2313
|
+
has_valid_perp_stop = False
|
|
2314
|
+
has_valid_spot_limit = False
|
|
2315
|
+
orders_to_cancel = []
|
|
2316
|
+
|
|
2317
|
+
# Spot coin name for matching (e.g., "@4" for HYPE spot)
|
|
2318
|
+
spot_coin = (
|
|
2319
|
+
f"@{spot_asset_id - 10000}"
|
|
2320
|
+
if spot_asset_id and spot_asset_id >= 10000
|
|
2321
|
+
else None
|
|
2322
|
+
)
|
|
2323
|
+
|
|
2324
|
+
if success:
|
|
2325
|
+
for order in open_orders:
|
|
2326
|
+
order_coin = order.get("coin", "")
|
|
2327
|
+
order_id = order.get("oid")
|
|
2328
|
+
is_trigger = order.get("isTrigger", False)
|
|
2329
|
+
order_type = str(order.get("orderType", "")).lower()
|
|
2330
|
+
is_sell = order.get("side", "").upper() == "A" # "A" = Ask/Sell
|
|
2331
|
+
|
|
2332
|
+
# Check PERP trigger orders (stop-loss)
|
|
2333
|
+
if order_coin == coin:
|
|
2334
|
+
is_trigger_order = (
|
|
2335
|
+
is_trigger or "stop" in order_type or "trigger" in order_type
|
|
2336
|
+
)
|
|
2337
|
+
|
|
2338
|
+
if is_trigger_order:
|
|
2339
|
+
existing_trigger = float(order.get("triggerPx", 0))
|
|
2340
|
+
existing_size = float(order.get("sz", 0))
|
|
2341
|
+
|
|
2342
|
+
if (
|
|
2343
|
+
existing_trigger < liquidation_price
|
|
2344
|
+
and existing_size >= position_size * 0.95
|
|
2345
|
+
and not has_valid_perp_stop
|
|
2346
|
+
):
|
|
2347
|
+
# First valid perp stop-loss found
|
|
2348
|
+
has_valid_perp_stop = True
|
|
2349
|
+
self.logger.info(
|
|
2350
|
+
f"Valid perp stop-loss exists for {coin} at {existing_trigger} "
|
|
2351
|
+
f"(size: {existing_size})"
|
|
2352
|
+
)
|
|
2353
|
+
else:
|
|
2354
|
+
# Invalid or duplicate - mark for cancellation
|
|
2355
|
+
if order_id:
|
|
2356
|
+
orders_to_cancel.append(
|
|
2357
|
+
(perp_asset_id, order_id, "perp stop-loss")
|
|
2358
|
+
)
|
|
2359
|
+
|
|
2360
|
+
# Check SPOT limit sell orders
|
|
2361
|
+
if spot_coin and order_coin == spot_coin and is_sell:
|
|
2362
|
+
# This is a spot sell order (could be our stop-loss limit)
|
|
2363
|
+
existing_price = float(order.get("limitPx", 0))
|
|
2364
|
+
existing_size = float(order.get("sz", 0))
|
|
2365
|
+
|
|
2366
|
+
# Check if it's around our stop-loss price (within 5%)
|
|
2367
|
+
price_match = (
|
|
2368
|
+
abs(existing_price - stop_loss_price) / stop_loss_price < 0.05
|
|
2369
|
+
)
|
|
2370
|
+
# Spot limit must cover at least 99% of spot holdings
|
|
2371
|
+
size_valid = existing_size >= (spot_position_size or 0) * 0.99
|
|
2372
|
+
|
|
2373
|
+
if price_match and size_valid and not has_valid_spot_limit:
|
|
2374
|
+
# First valid spot limit sell found
|
|
2375
|
+
has_valid_spot_limit = True
|
|
2376
|
+
self.logger.info(
|
|
2377
|
+
f"Valid spot limit sell exists for {spot_coin} at {existing_price} "
|
|
2378
|
+
f"(size: {existing_size})"
|
|
2379
|
+
)
|
|
2380
|
+
elif not is_trigger:
|
|
2381
|
+
# Invalid or duplicate spot limit - mark for cancellation
|
|
2382
|
+
# But only cancel if it's a limit order (not trigger)
|
|
2383
|
+
if order_id:
|
|
2384
|
+
orders_to_cancel.append(
|
|
2385
|
+
(spot_asset_id, order_id, "spot limit")
|
|
2386
|
+
)
|
|
2387
|
+
|
|
2388
|
+
# Cancel invalid/duplicate orders
|
|
2389
|
+
for asset_id, order_id, order_desc in orders_to_cancel:
|
|
2390
|
+
self.logger.info(f"Canceling {order_desc} order {order_id}")
|
|
2391
|
+
await self.hyperliquid_adapter.cancel_order(
|
|
2392
|
+
asset_id=asset_id,
|
|
2393
|
+
order_id=order_id,
|
|
2394
|
+
address=address,
|
|
2395
|
+
)
|
|
2396
|
+
|
|
2397
|
+
# Place perp stop-loss if not valid one exists
|
|
2398
|
+
if not has_valid_perp_stop:
|
|
2399
|
+
success, result = await self.hyperliquid_adapter.place_stop_loss(
|
|
2400
|
+
asset_id=perp_asset_id,
|
|
2401
|
+
is_buy=True, # Buy to close short
|
|
2402
|
+
trigger_price=stop_loss_price,
|
|
2403
|
+
size=position_size,
|
|
2404
|
+
address=address,
|
|
2405
|
+
)
|
|
2406
|
+
if not success:
|
|
2407
|
+
return False, f"Failed to place perp stop-loss: {result}"
|
|
2408
|
+
self.logger.info(f"Placed perp stop-loss at {stop_loss_price} for {coin}")
|
|
2409
|
+
|
|
2410
|
+
# Place spot limit sell if needed
|
|
2411
|
+
if (
|
|
2412
|
+
spot_asset_id
|
|
2413
|
+
and spot_position_size
|
|
2414
|
+
and spot_position_size > 0
|
|
2415
|
+
and not has_valid_spot_limit
|
|
2416
|
+
):
|
|
2417
|
+
# Get valid order size for spot
|
|
2418
|
+
spot_sell_size = self.hyperliquid_adapter.get_valid_order_size(
|
|
2419
|
+
spot_asset_id, spot_position_size
|
|
2420
|
+
)
|
|
2421
|
+
if spot_sell_size > 0:
|
|
2422
|
+
success, result = await self.hyperliquid_adapter.place_limit_order(
|
|
2423
|
+
asset_id=spot_asset_id,
|
|
2424
|
+
is_buy=False, # Sell
|
|
2425
|
+
price=stop_loss_price,
|
|
2426
|
+
size=spot_sell_size,
|
|
2427
|
+
address=address,
|
|
2428
|
+
reduce_only=False, # Spot doesn't have reduce_only
|
|
2429
|
+
)
|
|
2430
|
+
if not success:
|
|
2431
|
+
self.logger.warning(f"Failed to place spot limit sell: {result}")
|
|
2432
|
+
else:
|
|
2433
|
+
self.logger.info(
|
|
2434
|
+
f"Placed spot limit sell at {stop_loss_price} for {spot_coin} "
|
|
2435
|
+
f"(size: {spot_sell_size})"
|
|
2436
|
+
)
|
|
2437
|
+
|
|
2438
|
+
return True, "Stop-loss orders verified/placed"
|
|
2439
|
+
|
|
2440
|
+
# ------------------------------------------------------------------ #
|
|
2441
|
+
# Rotation Cooldown #
|
|
2442
|
+
# ------------------------------------------------------------------ #
|
|
2443
|
+
|
|
2444
|
+
async def _get_last_rotation_time(self) -> datetime | None:
|
|
2445
|
+
"""Get timestamp of last position rotation from ledger."""
|
|
2446
|
+
wallet_address = self._get_strategy_wallet_address()
|
|
2447
|
+
|
|
2448
|
+
try:
|
|
2449
|
+
success, transactions = await self.ledger_adapter.get_strategy_transactions(
|
|
2450
|
+
wallet_address=wallet_address,
|
|
2451
|
+
limit=50,
|
|
2452
|
+
)
|
|
2453
|
+
if not success or not transactions:
|
|
2454
|
+
return None
|
|
2455
|
+
|
|
2456
|
+
# Find most recent spot buy transaction (indicates rotation)
|
|
2457
|
+
for txn in transactions:
|
|
2458
|
+
data = txn.get("data", {})
|
|
2459
|
+
op_data = data.get("op_data", {})
|
|
2460
|
+
if (
|
|
2461
|
+
op_data.get("type") == "HYPE_SPOT"
|
|
2462
|
+
and op_data.get("buy_or_sell") == "buy"
|
|
2463
|
+
):
|
|
2464
|
+
created_at = txn.get("created_at")
|
|
2465
|
+
if created_at:
|
|
2466
|
+
return datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
2467
|
+
|
|
2468
|
+
return None
|
|
2469
|
+
except Exception as e:
|
|
2470
|
+
self.logger.warning(f"Could not get last rotation time: {e}")
|
|
2471
|
+
return None
|
|
2472
|
+
|
|
2473
|
+
async def _is_rotation_allowed(self) -> tuple[bool, str]:
|
|
2474
|
+
"""Check if rotation cooldown has passed."""
|
|
2475
|
+
if self.current_position is None:
|
|
2476
|
+
return True, "No existing position"
|
|
2477
|
+
|
|
2478
|
+
last_rotation = await self._get_last_rotation_time()
|
|
2479
|
+
if last_rotation is None:
|
|
2480
|
+
return True, "No prior rotation found"
|
|
2481
|
+
|
|
2482
|
+
now = datetime.now(UTC)
|
|
2483
|
+
# Ensure last_rotation is timezone-aware
|
|
2484
|
+
if last_rotation.tzinfo is None:
|
|
2485
|
+
last_rotation = last_rotation.replace(tzinfo=UTC)
|
|
2486
|
+
|
|
2487
|
+
elapsed = now - last_rotation
|
|
2488
|
+
cooldown = timedelta(days=self.ROTATION_MIN_INTERVAL_DAYS)
|
|
2489
|
+
|
|
2490
|
+
if elapsed >= cooldown:
|
|
2491
|
+
return True, "Cooldown passed"
|
|
2492
|
+
|
|
2493
|
+
remaining = cooldown - elapsed
|
|
2494
|
+
return False, f"Rotation cooldown: {remaining.days} days remaining"
|
|
2495
|
+
|
|
2496
|
+
# ------------------------------------------------------------------ #
|
|
2497
|
+
# Live Portfolio Value #
|
|
2498
|
+
# ------------------------------------------------------------------ #
|
|
2499
|
+
|
|
2500
|
+
async def _get_total_portfolio_value(self) -> tuple[float, float, float]:
|
|
2501
|
+
"""
|
|
2502
|
+
Get total portfolio value including Hyperliquid and vault balances.
|
|
2503
|
+
|
|
2504
|
+
Returns:
|
|
2505
|
+
(total_value, hyperliquid_value, vault_wallet_value)
|
|
2506
|
+
"""
|
|
2507
|
+
address = self._get_strategy_wallet_address()
|
|
2508
|
+
|
|
2509
|
+
# Get Hyperliquid account value
|
|
2510
|
+
hl_value = 0.0
|
|
2511
|
+
success, user_state = await self.hyperliquid_adapter.get_user_state(address)
|
|
2512
|
+
if success:
|
|
2513
|
+
margin_summary = user_state.get("marginSummary", {})
|
|
2514
|
+
hl_value = float(margin_summary.get("accountValue", 0))
|
|
2515
|
+
|
|
2516
|
+
# Add spot value (all spot holdings, not just USDC)
|
|
2517
|
+
(
|
|
2518
|
+
success_spot,
|
|
2519
|
+
spot_state,
|
|
2520
|
+
) = await self.hyperliquid_adapter.get_spot_user_state(address)
|
|
2521
|
+
if success_spot:
|
|
2522
|
+
spot_balances = spot_state.get("balances", [])
|
|
2523
|
+
# Get mid prices for non-USDC assets
|
|
2524
|
+
mid_prices: dict[str, float] = {}
|
|
2525
|
+
if any(bal.get("coin") != "USDC" for bal in spot_balances):
|
|
2526
|
+
(
|
|
2527
|
+
success_mids,
|
|
2528
|
+
mids,
|
|
2529
|
+
) = await self.hyperliquid_adapter.get_all_mid_prices()
|
|
2530
|
+
if success_mids:
|
|
2531
|
+
mid_prices = mids
|
|
2532
|
+
|
|
2533
|
+
for bal in spot_balances:
|
|
2534
|
+
coin = bal.get("coin", "")
|
|
2535
|
+
total = float(bal.get("total", 0))
|
|
2536
|
+
if total <= 0:
|
|
2537
|
+
continue
|
|
2538
|
+
|
|
2539
|
+
if coin == "USDC":
|
|
2540
|
+
# USDC is 1:1
|
|
2541
|
+
hl_value += total
|
|
2542
|
+
else:
|
|
2543
|
+
# Look up mid price for non-USDC assets
|
|
2544
|
+
mid_price = mid_prices.get(coin, 0.0)
|
|
2545
|
+
if mid_price > 0:
|
|
2546
|
+
hl_value += total * mid_price
|
|
2547
|
+
else:
|
|
2548
|
+
self.logger.debug(
|
|
2549
|
+
f"No mid price found for spot {coin}, skipping"
|
|
2550
|
+
)
|
|
2551
|
+
|
|
2552
|
+
# Get strategy wallet USDC balance (on Arbitrum)
|
|
2553
|
+
strategy_wallet_value = 0.0
|
|
2554
|
+
try:
|
|
2555
|
+
strategy_address = self._get_strategy_wallet_address()
|
|
2556
|
+
success, balance = await self.balance_adapter.get_balance(
|
|
2557
|
+
token_id=USDC_ARBITRUM_TOKEN_ID,
|
|
2558
|
+
wallet_address=strategy_address,
|
|
2559
|
+
)
|
|
2560
|
+
if success and balance:
|
|
2561
|
+
strategy_wallet_value = float(balance) / 1e6 # Convert from raw to USDC
|
|
2562
|
+
except Exception as e:
|
|
2563
|
+
self.logger.debug(f"Could not fetch strategy wallet balance: {e}")
|
|
2564
|
+
|
|
2565
|
+
total_value = hl_value + strategy_wallet_value
|
|
2566
|
+
return total_value, hl_value, strategy_wallet_value
|
|
2567
|
+
|
|
2568
|
+
# ------------------------------------------------------------------ #
|
|
2569
|
+
# Analysis Methods (ported from BasisTradingService) #
|
|
2570
|
+
# ------------------------------------------------------------------ #
|
|
2571
|
+
|
|
2572
|
+
async def find_best_basis_trades(
|
|
2573
|
+
self,
|
|
2574
|
+
deposit_usdc: float,
|
|
2575
|
+
lookback_days: int = 180,
|
|
2576
|
+
confidence: float = 0.975,
|
|
2577
|
+
fee_eps: float = 0.003,
|
|
2578
|
+
oi_floor: float = 50.0,
|
|
2579
|
+
day_vlm_floor: float = 1e5,
|
|
2580
|
+
horizons_days: list[int] | None = None,
|
|
2581
|
+
max_leverage: int = 3,
|
|
2582
|
+
) -> list[dict[str, Any]]:
|
|
2583
|
+
"""
|
|
2584
|
+
Find optimal basis trading opportunities.
|
|
2585
|
+
|
|
2586
|
+
Args:
|
|
2587
|
+
deposit_usdc: Total deposit amount in USDC
|
|
2588
|
+
lookback_days: Days of historical data to analyze
|
|
2589
|
+
confidence: VaR confidence level (default 97.5%)
|
|
2590
|
+
fee_eps: Fee buffer as fraction of notional
|
|
2591
|
+
oi_floor: Minimum open interest threshold in USD
|
|
2592
|
+
day_vlm_floor: Minimum daily volume threshold in USD
|
|
2593
|
+
horizons_days: Time horizons for risk calculation
|
|
2594
|
+
max_leverage: Maximum leverage allowed
|
|
2595
|
+
|
|
2596
|
+
Returns:
|
|
2597
|
+
List of basis trade opportunities sorted by expected APY
|
|
2598
|
+
"""
|
|
2599
|
+
if horizons_days is None:
|
|
2600
|
+
horizons_days = [1, 7]
|
|
2601
|
+
|
|
2602
|
+
# Validate lookback doesn't exceed HL's 5000 candle limit
|
|
2603
|
+
max_hours = 5000
|
|
2604
|
+
max_days = max_hours // 24
|
|
2605
|
+
if lookback_days > max_days:
|
|
2606
|
+
self.logger.warning(
|
|
2607
|
+
f"Lookback {lookback_days}d exceeds limit. Capping at {max_days}d"
|
|
2608
|
+
)
|
|
2609
|
+
lookback_days = max_days
|
|
2610
|
+
|
|
2611
|
+
try:
|
|
2612
|
+
# Get perpetual market data
|
|
2613
|
+
(
|
|
2614
|
+
success,
|
|
2615
|
+
perps_ctx_pack,
|
|
2616
|
+
) = await self.hyperliquid_adapter.get_meta_and_asset_ctxs()
|
|
2617
|
+
if not success:
|
|
2618
|
+
raise ValueError(f"Failed to fetch perp metadata: {perps_ctx_pack}")
|
|
2619
|
+
|
|
2620
|
+
perps_meta_list = perps_ctx_pack[0]["universe"]
|
|
2621
|
+
perps_ctxs = perps_ctx_pack[1]
|
|
2622
|
+
|
|
2623
|
+
coin_to_ctx: dict[str, Any] = {}
|
|
2624
|
+
coin_to_maxlev: dict[str, int] = {}
|
|
2625
|
+
coin_to_margin_table: dict[str, int | None] = {}
|
|
2626
|
+
coins: list[str] = []
|
|
2627
|
+
|
|
2628
|
+
for meta, ctx in zip(perps_meta_list, perps_ctxs, strict=False):
|
|
2629
|
+
coin = meta["name"]
|
|
2630
|
+
coin_to_ctx[coin] = ctx
|
|
2631
|
+
coin_to_maxlev[coin] = int(meta.get("maxLeverage", 10))
|
|
2632
|
+
coin_to_margin_table[coin] = meta.get("marginTableId")
|
|
2633
|
+
coins.append(coin)
|
|
2634
|
+
|
|
2635
|
+
perps_set = set(coins)
|
|
2636
|
+
|
|
2637
|
+
# Get spot market data
|
|
2638
|
+
success, spot_meta = await self.hyperliquid_adapter.get_spot_meta()
|
|
2639
|
+
if not success:
|
|
2640
|
+
raise ValueError(f"Failed to fetch spot metadata: {spot_meta}")
|
|
2641
|
+
|
|
2642
|
+
tokens = spot_meta.get("tokens", [])
|
|
2643
|
+
spot_pairs = spot_meta.get("universe", [])
|
|
2644
|
+
idx_to_token = {t["index"]: t["name"] for t in tokens}
|
|
2645
|
+
|
|
2646
|
+
# Find candidate basis pairs
|
|
2647
|
+
candidates = self._find_basis_candidates(
|
|
2648
|
+
spot_pairs, idx_to_token, perps_set
|
|
2649
|
+
)
|
|
2650
|
+
self.logger.info(f"Found {len(candidates)} spot-perp candidate pairs")
|
|
2651
|
+
|
|
2652
|
+
# Get perp asset ID mapping
|
|
2653
|
+
perp_coin_to_asset_id = {
|
|
2654
|
+
k: v
|
|
2655
|
+
for k, v in self.hyperliquid_adapter.coin_to_asset.items()
|
|
2656
|
+
if v < 10000
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
# Filter by liquidity
|
|
2660
|
+
liquid = await self._filter_by_liquidity(
|
|
2661
|
+
candidates=candidates,
|
|
2662
|
+
coin_to_ctx=coin_to_ctx,
|
|
2663
|
+
coin_to_maxlev=coin_to_maxlev,
|
|
2664
|
+
coin_to_margin_table=coin_to_margin_table,
|
|
2665
|
+
deposit_usdc=deposit_usdc,
|
|
2666
|
+
max_leverage=max_leverage,
|
|
2667
|
+
oi_floor=oi_floor,
|
|
2668
|
+
day_vlm_floor=day_vlm_floor,
|
|
2669
|
+
perp_coin_to_asset_id=perp_coin_to_asset_id,
|
|
2670
|
+
)
|
|
2671
|
+
self.logger.info(
|
|
2672
|
+
f"After liquidity filter: {len(liquid)} candidates "
|
|
2673
|
+
f"(OI >= ${oi_floor}, volume >= ${day_vlm_floor:,.0f})"
|
|
2674
|
+
)
|
|
2675
|
+
|
|
2676
|
+
# Analyze each candidate
|
|
2677
|
+
results = await self._analyze_candidates(
|
|
2678
|
+
liquid,
|
|
2679
|
+
deposit_usdc,
|
|
2680
|
+
lookback_days,
|
|
2681
|
+
confidence,
|
|
2682
|
+
fee_eps,
|
|
2683
|
+
horizons_days,
|
|
2684
|
+
)
|
|
2685
|
+
self.logger.info(f"After historical analysis: {len(results)} opportunities")
|
|
2686
|
+
|
|
2687
|
+
# Sort by expected APY
|
|
2688
|
+
results.sort(key=self._get_safe_apy_key, reverse=True)
|
|
2689
|
+
return results
|
|
2690
|
+
|
|
2691
|
+
except Exception as e:
|
|
2692
|
+
self.logger.error(f"Error finding basis trades: {e}")
|
|
2693
|
+
raise
|
|
2694
|
+
|
|
2695
|
+
def _find_basis_candidates(
|
|
2696
|
+
self,
|
|
2697
|
+
spot_pairs: list[dict],
|
|
2698
|
+
idx_to_token: dict[int, str],
|
|
2699
|
+
perps_set: set[str],
|
|
2700
|
+
) -> list[tuple[str, str, int]]:
|
|
2701
|
+
"""Find spot-perp pairs that can form basis trades."""
|
|
2702
|
+
candidates: list[tuple[str, str, int]] = []
|
|
2703
|
+
|
|
2704
|
+
for pe in spot_pairs:
|
|
2705
|
+
base_idx = pe["tokens"][0]
|
|
2706
|
+
quote_idx = pe["tokens"][1]
|
|
2707
|
+
base = idx_to_token.get(base_idx)
|
|
2708
|
+
quote = idx_to_token.get(quote_idx)
|
|
2709
|
+
|
|
2710
|
+
if quote != "USDC":
|
|
2711
|
+
continue
|
|
2712
|
+
|
|
2713
|
+
if not base or not quote:
|
|
2714
|
+
continue
|
|
2715
|
+
|
|
2716
|
+
spot_pair_name = f"{base}/{quote}"
|
|
2717
|
+
spot_asset_id = pe["index"] + 10000
|
|
2718
|
+
|
|
2719
|
+
# Handle USDT prefixed tokens (UPUMP -> PUMP)
|
|
2720
|
+
base_norm = (
|
|
2721
|
+
base[1:] if (base.startswith("U") and base[1:] in perps_set) else base
|
|
2722
|
+
)
|
|
2723
|
+
if base_norm in perps_set:
|
|
2724
|
+
candidates.append((spot_pair_name, base_norm, spot_asset_id))
|
|
2725
|
+
|
|
2726
|
+
return candidates
|
|
2727
|
+
|
|
2728
|
+
async def _filter_by_liquidity(
|
|
2729
|
+
self,
|
|
2730
|
+
candidates: list[tuple[str, str, int]],
|
|
2731
|
+
coin_to_ctx: dict[str, Any],
|
|
2732
|
+
coin_to_maxlev: dict[str, int],
|
|
2733
|
+
coin_to_margin_table: dict[str, int | None],
|
|
2734
|
+
deposit_usdc: float,
|
|
2735
|
+
max_leverage: int,
|
|
2736
|
+
oi_floor: float,
|
|
2737
|
+
day_vlm_floor: float,
|
|
2738
|
+
perp_coin_to_asset_id: dict[str, int],
|
|
2739
|
+
depth_params: dict[str, Any] | None = None,
|
|
2740
|
+
) -> list[BasisCandidate]:
|
|
2741
|
+
"""Filter candidates by liquidity and venue depth, returning structured data."""
|
|
2742
|
+
liquid: list[BasisCandidate] = []
|
|
2743
|
+
|
|
2744
|
+
if deposit_usdc <= 0:
|
|
2745
|
+
return liquid
|
|
2746
|
+
|
|
2747
|
+
for spot_sym, coin, spot_asset_id in candidates:
|
|
2748
|
+
ctx = coin_to_ctx.get(coin, {})
|
|
2749
|
+
oi_base = float(ctx.get("openInterest") or 0.0)
|
|
2750
|
+
mark_px = float(ctx.get("markPx") or 0.0)
|
|
2751
|
+
|
|
2752
|
+
if mark_px <= 0:
|
|
2753
|
+
continue
|
|
2754
|
+
|
|
2755
|
+
perp_asset_id = perp_coin_to_asset_id.get(coin)
|
|
2756
|
+
if perp_asset_id is None:
|
|
2757
|
+
continue
|
|
2758
|
+
|
|
2759
|
+
margin_table_id = coin_to_margin_table.get(coin)
|
|
2760
|
+
oi_usd = oi_base * mark_px
|
|
2761
|
+
day_ntl_usd = float(ctx.get("dayNtlVlm") or 0.0)
|
|
2762
|
+
|
|
2763
|
+
# Apply liquidity filters
|
|
2764
|
+
if oi_usd < oi_floor or day_ntl_usd < day_vlm_floor:
|
|
2765
|
+
continue
|
|
2766
|
+
|
|
2767
|
+
raw_max_lev = coin_to_maxlev.get(coin, max_leverage)
|
|
2768
|
+
coin_max_lev = int(raw_max_lev) if raw_max_lev else max_leverage
|
|
2769
|
+
target_leverage = max(1, min(max_leverage, coin_max_lev))
|
|
2770
|
+
order_usd = deposit_usdc * (target_leverage / (target_leverage + 1))
|
|
2771
|
+
|
|
2772
|
+
if order_usd <= 0:
|
|
2773
|
+
continue
|
|
2774
|
+
|
|
2775
|
+
# Get spot order book
|
|
2776
|
+
try:
|
|
2777
|
+
book_snapshot = await self._l2_book_spot(
|
|
2778
|
+
spot_asset_id,
|
|
2779
|
+
fallback_mid=mark_px,
|
|
2780
|
+
spot_symbol=spot_sym,
|
|
2781
|
+
)
|
|
2782
|
+
except Exception as exc:
|
|
2783
|
+
self.logger.warning(f"Skipping {spot_sym}: L2 fetch error: {exc}")
|
|
2784
|
+
continue
|
|
2785
|
+
|
|
2786
|
+
buy_check = await self.check_spot_depth_ok(
|
|
2787
|
+
spot_asset_id,
|
|
2788
|
+
order_usd,
|
|
2789
|
+
"buy",
|
|
2790
|
+
day_ntl_usd=day_ntl_usd,
|
|
2791
|
+
params=depth_params,
|
|
2792
|
+
book=book_snapshot,
|
|
2793
|
+
)
|
|
2794
|
+
sell_check = await self.check_spot_depth_ok(
|
|
2795
|
+
spot_asset_id,
|
|
2796
|
+
order_usd,
|
|
2797
|
+
"sell",
|
|
2798
|
+
day_ntl_usd=day_ntl_usd,
|
|
2799
|
+
params=depth_params,
|
|
2800
|
+
book=book_snapshot,
|
|
2801
|
+
)
|
|
2802
|
+
|
|
2803
|
+
if not (buy_check.get("pass") and sell_check.get("pass")):
|
|
2804
|
+
continue
|
|
2805
|
+
|
|
2806
|
+
depth_checks = {"buy": buy_check, "sell": sell_check}
|
|
2807
|
+
|
|
2808
|
+
liquid.append(
|
|
2809
|
+
BasisCandidate(
|
|
2810
|
+
coin=coin,
|
|
2811
|
+
spot_pair=spot_sym,
|
|
2812
|
+
spot_asset_id=spot_asset_id,
|
|
2813
|
+
perp_asset_id=perp_asset_id,
|
|
2814
|
+
mark_price=mark_px,
|
|
2815
|
+
target_leverage=target_leverage,
|
|
2816
|
+
ctx=ctx,
|
|
2817
|
+
spot_book=book_snapshot,
|
|
2818
|
+
open_interest_base=oi_base,
|
|
2819
|
+
open_interest_usd=oi_usd,
|
|
2820
|
+
day_notional_usd=day_ntl_usd,
|
|
2821
|
+
order_usd=order_usd,
|
|
2822
|
+
depth_checks=depth_checks,
|
|
2823
|
+
margin_table_id=margin_table_id,
|
|
2824
|
+
)
|
|
2825
|
+
)
|
|
2826
|
+
|
|
2827
|
+
return liquid
|
|
2828
|
+
|
|
2829
|
+
async def _analyze_candidates(
|
|
2830
|
+
self,
|
|
2831
|
+
candidates: list[BasisCandidate],
|
|
2832
|
+
deposit_usdc: float,
|
|
2833
|
+
lookback_days: int,
|
|
2834
|
+
confidence: float,
|
|
2835
|
+
fee_eps: float,
|
|
2836
|
+
horizons_days: list[int],
|
|
2837
|
+
) -> list[dict[str, Any]]:
|
|
2838
|
+
"""Analyze each liquid candidate for basis trading metrics."""
|
|
2839
|
+
ms_now = int(time.time() * 1000)
|
|
2840
|
+
start_ms = ms_now - int(lookback_days * 24 * 3600 * 1000)
|
|
2841
|
+
z = self._z_from_conf(confidence)
|
|
2842
|
+
|
|
2843
|
+
results: list[dict[str, Any]] = []
|
|
2844
|
+
|
|
2845
|
+
required_hours = lookback_days * 24
|
|
2846
|
+
skipped_reasons: dict[str, list[str]] = {
|
|
2847
|
+
"no_funding": [],
|
|
2848
|
+
"no_candles": [],
|
|
2849
|
+
"insufficient_funding": [],
|
|
2850
|
+
"insufficient_candles": [],
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
for candidate in candidates:
|
|
2854
|
+
coin = candidate.coin
|
|
2855
|
+
spot_sym = candidate.spot_pair
|
|
2856
|
+
|
|
2857
|
+
# Fetch funding history with chunking for longer lookbacks
|
|
2858
|
+
success, funding_data = await self._fetch_funding_history_chunked(
|
|
2859
|
+
coin, start_ms, ms_now
|
|
2860
|
+
)
|
|
2861
|
+
if not success or not funding_data:
|
|
2862
|
+
skipped_reasons["no_funding"].append(coin)
|
|
2863
|
+
continue
|
|
2864
|
+
|
|
2865
|
+
hourly_funding = [float(x.get("fundingRate", 0.0)) for x in funding_data]
|
|
2866
|
+
|
|
2867
|
+
# Fetch candle data with chunking for longer lookbacks
|
|
2868
|
+
success, candle_data = await self._fetch_candles_chunked(
|
|
2869
|
+
coin, "1h", start_ms, ms_now
|
|
2870
|
+
)
|
|
2871
|
+
if not success or not candle_data:
|
|
2872
|
+
skipped_reasons["no_candles"].append(coin)
|
|
2873
|
+
continue
|
|
2874
|
+
|
|
2875
|
+
closes = [float(c.get("c", 0)) for c in candle_data if c.get("c")]
|
|
2876
|
+
highs = [float(c.get("h", 0)) for c in candle_data if c.get("h")]
|
|
2877
|
+
|
|
2878
|
+
# Require at least 7 days of data minimum, or 50% of lookback for longer periods
|
|
2879
|
+
min_required = max(7 * 24, required_hours // 2)
|
|
2880
|
+
if len(hourly_funding) < min_required:
|
|
2881
|
+
skipped_reasons["insufficient_funding"].append(
|
|
2882
|
+
f"{coin}({len(hourly_funding)}/{min_required})"
|
|
2883
|
+
)
|
|
2884
|
+
continue
|
|
2885
|
+
if len(closes) < min_required or len(highs) < min_required:
|
|
2886
|
+
skipped_reasons["insufficient_candles"].append(
|
|
2887
|
+
f"{coin}(closes={len(closes)},highs={len(highs)}/{min_required})"
|
|
2888
|
+
)
|
|
2889
|
+
continue
|
|
2890
|
+
|
|
2891
|
+
# Calculate price volatility
|
|
2892
|
+
sigma_hourly = (
|
|
2893
|
+
pstdev(
|
|
2894
|
+
[(closes[i] / closes[i - 1] - 1.0) for i in range(1, len(closes))]
|
|
2895
|
+
)
|
|
2896
|
+
if len(closes) > 1
|
|
2897
|
+
else 0.005
|
|
2898
|
+
)
|
|
2899
|
+
|
|
2900
|
+
# Calculate funding statistics
|
|
2901
|
+
funding_stats = self._calculate_funding_stats(hourly_funding)
|
|
2902
|
+
|
|
2903
|
+
# Calculate safe leverages
|
|
2904
|
+
max_lev = candidate.target_leverage
|
|
2905
|
+
m_maint = self.maintenance_rate_from_max_leverage(max_lev)
|
|
2906
|
+
|
|
2907
|
+
safe = self._calculate_safe_leverages(
|
|
2908
|
+
hourly_funding=hourly_funding,
|
|
2909
|
+
closes=closes,
|
|
2910
|
+
highs=highs,
|
|
2911
|
+
z=z,
|
|
2912
|
+
m_maint=m_maint,
|
|
2913
|
+
fee_eps=fee_eps,
|
|
2914
|
+
max_lev=max_lev,
|
|
2915
|
+
deposit_usdc=deposit_usdc,
|
|
2916
|
+
horizons_days=horizons_days,
|
|
2917
|
+
)
|
|
2918
|
+
|
|
2919
|
+
result = {
|
|
2920
|
+
"coin": coin,
|
|
2921
|
+
"spot_pair": spot_sym,
|
|
2922
|
+
"spot_asset_id": candidate.spot_asset_id,
|
|
2923
|
+
"perp_asset_id": candidate.perp_asset_id,
|
|
2924
|
+
"maxLeverage": max_lev,
|
|
2925
|
+
"maintenance_rate_est": m_maint,
|
|
2926
|
+
"openInterest": candidate.open_interest_base,
|
|
2927
|
+
"day_notional_volume": candidate.day_notional_usd,
|
|
2928
|
+
"funding_stats": funding_stats,
|
|
2929
|
+
"price_stats": {
|
|
2930
|
+
"sigma_hourly": sigma_hourly,
|
|
2931
|
+
"z_for_confidence": z,
|
|
2932
|
+
"confidence": confidence,
|
|
2933
|
+
},
|
|
2934
|
+
"safe": safe,
|
|
2935
|
+
"depth_checks": candidate.depth_checks,
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
results.append(result)
|
|
2939
|
+
|
|
2940
|
+
# Log skip reasons
|
|
2941
|
+
for reason, coins in skipped_reasons.items():
|
|
2942
|
+
if coins:
|
|
2943
|
+
self.logger.debug(
|
|
2944
|
+
f"Skipped ({reason}): {', '.join(coins[:5])}{'...' if len(coins) > 5 else ''}"
|
|
2945
|
+
)
|
|
2946
|
+
|
|
2947
|
+
return results
|
|
2948
|
+
|
|
2949
|
+
def _calculate_funding_stats(self, hourly_funding: list[float]) -> dict[str, Any]:
|
|
2950
|
+
"""Calculate comprehensive funding rate statistics."""
|
|
2951
|
+
if not hourly_funding:
|
|
2952
|
+
return {
|
|
2953
|
+
"mean_hourly": 0.0,
|
|
2954
|
+
"neg_hour_fraction": 0.0,
|
|
2955
|
+
"hourly_vol": 0.0,
|
|
2956
|
+
"worst_24h_sum": 0.0,
|
|
2957
|
+
"worst_7d_sum": 0.0,
|
|
2958
|
+
"points": 0,
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
mean_hourly = mean(hourly_funding)
|
|
2962
|
+
neg_hour_frac = sum(1 for r in hourly_funding if r < 0.0) / len(hourly_funding)
|
|
2963
|
+
hourly_vol = pstdev(hourly_funding) if len(hourly_funding) > 1 else 0.0
|
|
2964
|
+
worst_24h = self._rolling_min_sum(hourly_funding, 24)
|
|
2965
|
+
worst_7d = self._rolling_min_sum(hourly_funding, 24 * 7)
|
|
2966
|
+
|
|
2967
|
+
return {
|
|
2968
|
+
"mean_hourly": mean_hourly,
|
|
2969
|
+
"neg_hour_fraction": neg_hour_frac,
|
|
2970
|
+
"hourly_vol": hourly_vol,
|
|
2971
|
+
"worst_24h_sum": worst_24h,
|
|
2972
|
+
"worst_7d_sum": worst_7d,
|
|
2973
|
+
"points": len(hourly_funding),
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
def _calculate_safe_leverages(
|
|
2977
|
+
self,
|
|
2978
|
+
hourly_funding: list[float],
|
|
2979
|
+
closes: list[float],
|
|
2980
|
+
highs: list[float],
|
|
2981
|
+
z: float,
|
|
2982
|
+
m_maint: float,
|
|
2983
|
+
fee_eps: float,
|
|
2984
|
+
max_lev: int,
|
|
2985
|
+
deposit_usdc: float,
|
|
2986
|
+
horizons_days: list[int],
|
|
2987
|
+
) -> dict[str, Any]:
|
|
2988
|
+
"""Calculate safe leverage for each time horizon."""
|
|
2989
|
+
results: dict[str, Any] = {}
|
|
2990
|
+
|
|
2991
|
+
for horizon in horizons_days:
|
|
2992
|
+
window = horizon * 24
|
|
2993
|
+
b_star = self._worst_buffer_requirement(
|
|
2994
|
+
closes, highs, hourly_funding, window, m_maint, fee_eps
|
|
2995
|
+
)
|
|
2996
|
+
|
|
2997
|
+
if b_star >= 1.0:
|
|
2998
|
+
results[f"{horizon}d"] = {
|
|
2999
|
+
"pass": False,
|
|
3000
|
+
"leverage": 0,
|
|
3001
|
+
"reason": "Buffer requirement exceeds 100%",
|
|
3002
|
+
}
|
|
3003
|
+
continue
|
|
3004
|
+
|
|
3005
|
+
safe_lev = min(max_lev, int(1.0 / b_star)) if b_star > 0 else max_lev
|
|
3006
|
+
|
|
3007
|
+
# Calculate expected APY
|
|
3008
|
+
mean_funding = mean(hourly_funding) if hourly_funding else 0
|
|
3009
|
+
expected_apy = mean_funding * 24 * 365 * safe_lev
|
|
3010
|
+
|
|
3011
|
+
# Estimate quantities
|
|
3012
|
+
order_usd = deposit_usdc * (safe_lev / (safe_lev + 1))
|
|
3013
|
+
avg_price = mean(closes) if closes else 1.0
|
|
3014
|
+
qty = order_usd / avg_price if avg_price > 0 else 0
|
|
3015
|
+
|
|
3016
|
+
results[f"{horizon}d"] = {
|
|
3017
|
+
"pass": True,
|
|
3018
|
+
"leverage": safe_lev,
|
|
3019
|
+
"buffer_requirement": b_star,
|
|
3020
|
+
"expected_apy_pct": expected_apy,
|
|
3021
|
+
"spot_qty": qty,
|
|
3022
|
+
"perp_qty": qty,
|
|
3023
|
+
"order_usd": order_usd,
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
return results
|
|
3027
|
+
|
|
3028
|
+
def _worst_buffer_requirement(
|
|
3029
|
+
self,
|
|
3030
|
+
closes: list[float],
|
|
3031
|
+
highs: list[float],
|
|
3032
|
+
hourly_funding: list[float],
|
|
3033
|
+
window: int,
|
|
3034
|
+
mmr: float,
|
|
3035
|
+
fee_eps: float,
|
|
3036
|
+
) -> float:
|
|
3037
|
+
"""
|
|
3038
|
+
Calculate worst-case buffer requirement over rolling windows.
|
|
3039
|
+
|
|
3040
|
+
Uses deterministic historical "stress test" approach.
|
|
3041
|
+
"""
|
|
3042
|
+
n = min(len(closes), len(highs), len(hourly_funding))
|
|
3043
|
+
if n < window or n == 0:
|
|
3044
|
+
return 1.0
|
|
3045
|
+
|
|
3046
|
+
worst_req = 0.0
|
|
3047
|
+
|
|
3048
|
+
for start in range(n - window + 1):
|
|
3049
|
+
end = start + window
|
|
3050
|
+
entry_price = closes[start]
|
|
3051
|
+
if entry_price <= 0:
|
|
3052
|
+
continue
|
|
3053
|
+
|
|
3054
|
+
cum_f = 0.0
|
|
3055
|
+
runup = 0.0
|
|
3056
|
+
|
|
3057
|
+
for i in range(start, end):
|
|
3058
|
+
peak = highs[i]
|
|
3059
|
+
step_runup = (peak / entry_price - 1.0) if entry_price > 0 else 0.0
|
|
3060
|
+
runup = max(runup, step_runup)
|
|
3061
|
+
|
|
3062
|
+
r = hourly_funding[i] if i < len(hourly_funding) else 0.0
|
|
3063
|
+
if r < 0.0:
|
|
3064
|
+
cum_f += (-r) * (1.0 + runup)
|
|
3065
|
+
|
|
3066
|
+
req = mmr * (1.0 + runup) + runup + cum_f + fee_eps
|
|
3067
|
+
if req > worst_req:
|
|
3068
|
+
worst_req = req
|
|
3069
|
+
|
|
3070
|
+
return worst_req
|
|
3071
|
+
|
|
3072
|
+
# ------------------------------------------------------------------ #
|
|
3073
|
+
# Chunked Data Fetching #
|
|
3074
|
+
# ------------------------------------------------------------------ #
|
|
3075
|
+
|
|
3076
|
+
HOURS_PER_CHUNK = 500 # Hyperliquid API returns max 500 data points per call
|
|
3077
|
+
CHUNK_DELAY_SECONDS = 0.2 # Delay between API chunks to avoid rate limiting
|
|
3078
|
+
|
|
3079
|
+
def _hour_chunks(
|
|
3080
|
+
self, start_ms: int, end_ms: int, step_hours: int = 500
|
|
3081
|
+
) -> list[tuple[int, int]]:
|
|
3082
|
+
"""
|
|
3083
|
+
Generate time chunks for API calls.
|
|
3084
|
+
|
|
3085
|
+
Each chunk is (start_ms, end_ms) tuple representing a time window
|
|
3086
|
+
of up to `step_hours` hours. This allows fetching >500 data points
|
|
3087
|
+
by making multiple API calls.
|
|
3088
|
+
|
|
3089
|
+
Args:
|
|
3090
|
+
start_ms: Start time in milliseconds
|
|
3091
|
+
end_ms: End time in milliseconds
|
|
3092
|
+
step_hours: Hours per chunk (default 500, Hyperliquid API limit)
|
|
3093
|
+
|
|
3094
|
+
Returns:
|
|
3095
|
+
List of (chunk_start_ms, chunk_end_ms) tuples
|
|
3096
|
+
"""
|
|
3097
|
+
chunks = []
|
|
3098
|
+
step_ms = step_hours * 3600 * 1000 # Convert hours to milliseconds
|
|
3099
|
+
t0 = start_ms
|
|
3100
|
+
|
|
3101
|
+
while t0 < end_ms:
|
|
3102
|
+
t1 = min(t0 + step_ms, end_ms)
|
|
3103
|
+
chunks.append((t0, t1))
|
|
3104
|
+
t0 = t1
|
|
3105
|
+
|
|
3106
|
+
return chunks
|
|
3107
|
+
|
|
3108
|
+
async def _fetch_funding_history_chunked(
|
|
3109
|
+
self,
|
|
3110
|
+
coin: str,
|
|
3111
|
+
start_ms: int,
|
|
3112
|
+
end_ms: int | None = None,
|
|
3113
|
+
) -> tuple[bool, list[dict[str, Any]]]:
|
|
3114
|
+
"""
|
|
3115
|
+
Fetch funding history with automatic chunking for long time ranges.
|
|
3116
|
+
|
|
3117
|
+
Hyperliquid API returns max ~500 data points per call. This method
|
|
3118
|
+
automatically splits long requests into multiple chunks and merges
|
|
3119
|
+
the results.
|
|
3120
|
+
|
|
3121
|
+
Args:
|
|
3122
|
+
coin: Coin symbol (e.g., "ETH", "BTC")
|
|
3123
|
+
start_ms: Start time in milliseconds
|
|
3124
|
+
end_ms: End time in milliseconds (defaults to now)
|
|
3125
|
+
|
|
3126
|
+
Returns:
|
|
3127
|
+
(success, combined_funding_data)
|
|
3128
|
+
"""
|
|
3129
|
+
if end_ms is None:
|
|
3130
|
+
end_ms = int(time.time() * 1000)
|
|
3131
|
+
|
|
3132
|
+
chunks = self._hour_chunks(start_ms, end_ms, self.HOURS_PER_CHUNK)
|
|
3133
|
+
all_funding: list[dict[str, Any]] = []
|
|
3134
|
+
seen_times: set[int] = set() # Dedupe by timestamp
|
|
3135
|
+
|
|
3136
|
+
for i, (chunk_start, chunk_end) in enumerate(chunks):
|
|
3137
|
+
# Add delay between chunks to avoid rate limiting
|
|
3138
|
+
if i > 0:
|
|
3139
|
+
await asyncio.sleep(self.CHUNK_DELAY_SECONDS)
|
|
3140
|
+
|
|
3141
|
+
success, data = await self.hyperliquid_adapter.get_funding_history(
|
|
3142
|
+
coin, chunk_start, chunk_end
|
|
3143
|
+
)
|
|
3144
|
+
if not success:
|
|
3145
|
+
# Log but continue with partial data
|
|
3146
|
+
self.logger.warning(
|
|
3147
|
+
f"Funding chunk failed for {coin} "
|
|
3148
|
+
f"({chunk_start} - {chunk_end}): {data}"
|
|
3149
|
+
)
|
|
3150
|
+
continue
|
|
3151
|
+
|
|
3152
|
+
# Dedupe and merge
|
|
3153
|
+
for record in data:
|
|
3154
|
+
ts = record.get("time", 0)
|
|
3155
|
+
if ts not in seen_times:
|
|
3156
|
+
seen_times.add(ts)
|
|
3157
|
+
all_funding.append(record)
|
|
3158
|
+
|
|
3159
|
+
# Sort by time
|
|
3160
|
+
all_funding.sort(key=lambda x: x.get("time", 0))
|
|
3161
|
+
|
|
3162
|
+
if not all_funding:
|
|
3163
|
+
return False, []
|
|
3164
|
+
|
|
3165
|
+
self.logger.debug(
|
|
3166
|
+
f"Fetched {len(all_funding)} funding points for {coin} "
|
|
3167
|
+
f"via {len(chunks)} chunk(s)"
|
|
3168
|
+
)
|
|
3169
|
+
return True, all_funding
|
|
3170
|
+
|
|
3171
|
+
async def _fetch_candles_chunked(
|
|
3172
|
+
self,
|
|
3173
|
+
coin: str,
|
|
3174
|
+
interval: str,
|
|
3175
|
+
start_ms: int,
|
|
3176
|
+
end_ms: int | None = None,
|
|
3177
|
+
) -> tuple[bool, list[dict[str, Any]]]:
|
|
3178
|
+
"""
|
|
3179
|
+
Fetch candle data with automatic chunking for long time ranges.
|
|
3180
|
+
|
|
3181
|
+
Args:
|
|
3182
|
+
coin: Coin symbol (e.g., "ETH", "BTC")
|
|
3183
|
+
interval: Candle interval (e.g., "1h")
|
|
3184
|
+
start_ms: Start time in milliseconds
|
|
3185
|
+
end_ms: End time in milliseconds (defaults to now)
|
|
3186
|
+
|
|
3187
|
+
Returns:
|
|
3188
|
+
(success, combined_candle_data)
|
|
3189
|
+
"""
|
|
3190
|
+
if end_ms is None:
|
|
3191
|
+
end_ms = int(time.time() * 1000)
|
|
3192
|
+
|
|
3193
|
+
chunks = self._hour_chunks(start_ms, end_ms, self.HOURS_PER_CHUNK)
|
|
3194
|
+
all_candles: list[dict[str, Any]] = []
|
|
3195
|
+
seen_times: set[int] = set() # Dedupe by open timestamp
|
|
3196
|
+
|
|
3197
|
+
for i, (chunk_start, chunk_end) in enumerate(chunks):
|
|
3198
|
+
# Add delay between chunks to avoid rate limiting
|
|
3199
|
+
if i > 0:
|
|
3200
|
+
await asyncio.sleep(self.CHUNK_DELAY_SECONDS)
|
|
3201
|
+
|
|
3202
|
+
success, data = await self.hyperliquid_adapter.get_candles(
|
|
3203
|
+
coin, interval, chunk_start, chunk_end
|
|
3204
|
+
)
|
|
3205
|
+
if not success:
|
|
3206
|
+
self.logger.warning(
|
|
3207
|
+
f"Candle chunk failed for {coin} "
|
|
3208
|
+
f"({chunk_start} - {chunk_end}): {data}"
|
|
3209
|
+
)
|
|
3210
|
+
continue
|
|
3211
|
+
|
|
3212
|
+
# Dedupe and merge
|
|
3213
|
+
for candle in data:
|
|
3214
|
+
ts = candle.get("t", 0)
|
|
3215
|
+
if ts not in seen_times:
|
|
3216
|
+
seen_times.add(ts)
|
|
3217
|
+
all_candles.append(candle)
|
|
3218
|
+
|
|
3219
|
+
# Sort by time
|
|
3220
|
+
all_candles.sort(key=lambda x: x.get("t", 0))
|
|
3221
|
+
|
|
3222
|
+
if not all_candles:
|
|
3223
|
+
return False, []
|
|
3224
|
+
|
|
3225
|
+
self.logger.debug(
|
|
3226
|
+
f"Fetched {len(all_candles)} candles for {coin} via {len(chunks)} chunk(s)"
|
|
3227
|
+
)
|
|
3228
|
+
return True, all_candles
|
|
3229
|
+
|
|
3230
|
+
# ------------------------------------------------------------------ #
|
|
3231
|
+
# Net APY Solver + Bootstrap (ported from Django NetApyBasisTradingService)
|
|
3232
|
+
# ------------------------------------------------------------------ #
|
|
3233
|
+
|
|
3234
|
+
def _spot_index_from_asset_id(self, spot_asset_id: int) -> int:
|
|
3235
|
+
return hl_spot_index_from_asset_id(spot_asset_id)
|
|
3236
|
+
|
|
3237
|
+
def _normalize_l2_book(
|
|
3238
|
+
self,
|
|
3239
|
+
raw: dict[str, Any],
|
|
3240
|
+
*,
|
|
3241
|
+
fallback_mid: float | None = None,
|
|
3242
|
+
) -> dict[str, Any]:
|
|
3243
|
+
"""Normalize Hyperliquid L2 into bids/asks lists with floats."""
|
|
3244
|
+
return hl_normalize_l2_book(raw, fallback_mid=fallback_mid)
|
|
3245
|
+
|
|
3246
|
+
async def _l2_book_spot(
|
|
3247
|
+
self,
|
|
3248
|
+
spot_asset_id: int,
|
|
3249
|
+
*,
|
|
3250
|
+
fallback_mid: float | None = None,
|
|
3251
|
+
spot_symbol: str | None = None,
|
|
3252
|
+
) -> dict[str, Any]:
|
|
3253
|
+
"""Fetch and normalize Level-2 order book snapshot for a spot asset."""
|
|
3254
|
+
last_exc: Exception | None = None
|
|
3255
|
+
|
|
3256
|
+
try:
|
|
3257
|
+
success, raw = await self.hyperliquid_adapter.get_spot_l2_book(
|
|
3258
|
+
spot_asset_id
|
|
3259
|
+
)
|
|
3260
|
+
if success and isinstance(raw, dict):
|
|
3261
|
+
return self._normalize_l2_book(raw, fallback_mid=fallback_mid)
|
|
3262
|
+
except Exception as exc: # noqa: BLE001
|
|
3263
|
+
last_exc = exc
|
|
3264
|
+
|
|
3265
|
+
# Fallback: try spot pair naming conventions
|
|
3266
|
+
# - Index 0: use "PURR/USDC"
|
|
3267
|
+
# - Other indices: use "@{index}"
|
|
3268
|
+
spot_index = self._spot_index_from_asset_id(spot_asset_id)
|
|
3269
|
+
if spot_index == 0:
|
|
3270
|
+
candidates = ["PURR/USDC"]
|
|
3271
|
+
else:
|
|
3272
|
+
candidates = [f"@{spot_index}"]
|
|
3273
|
+
|
|
3274
|
+
# Also try the spot_symbol if provided (e.g., "HYPE/USDC")
|
|
3275
|
+
if spot_symbol:
|
|
3276
|
+
candidates.append(spot_symbol)
|
|
3277
|
+
|
|
3278
|
+
seen: set[str] = set()
|
|
3279
|
+
for coin in candidates:
|
|
3280
|
+
if not coin or coin in seen:
|
|
3281
|
+
continue
|
|
3282
|
+
seen.add(coin)
|
|
3283
|
+
try:
|
|
3284
|
+
# Use get_l2_book which accepts spot pair names like "PURR/USDC" or "@107"
|
|
3285
|
+
success, raw = await self.hyperliquid_adapter.get_l2_book(coin)
|
|
3286
|
+
if success and isinstance(raw, dict):
|
|
3287
|
+
return self._normalize_l2_book(raw, fallback_mid=fallback_mid)
|
|
3288
|
+
except Exception as exc: # noqa: BLE001
|
|
3289
|
+
last_exc = exc
|
|
3290
|
+
continue
|
|
3291
|
+
|
|
3292
|
+
if last_exc is not None:
|
|
3293
|
+
raise last_exc
|
|
3294
|
+
raise ValueError(f"Unable to fetch L2 book for spot asset {spot_asset_id}")
|
|
3295
|
+
|
|
3296
|
+
def _usd_depth_in_band(
|
|
3297
|
+
self, book: dict[str, Any], band_bps: int, side: str
|
|
3298
|
+
) -> tuple[float, float]:
|
|
3299
|
+
return hl_usd_depth_in_band(book, band_bps, side)
|
|
3300
|
+
|
|
3301
|
+
def _depth_band_for_size(
|
|
3302
|
+
self,
|
|
3303
|
+
order_usd: float,
|
|
3304
|
+
*,
|
|
3305
|
+
base_bps: int = 20,
|
|
3306
|
+
max_bps: int = 100,
|
|
3307
|
+
gamma: int = 20,
|
|
3308
|
+
) -> int:
|
|
3309
|
+
"""Widen the depth band slowly with order size."""
|
|
3310
|
+
if order_usd <= 0:
|
|
3311
|
+
return base_bps
|
|
3312
|
+
|
|
3313
|
+
band = base_bps + int(gamma * max(0.0, math.log10(order_usd / 1e4)))
|
|
3314
|
+
band = max(base_bps, band)
|
|
3315
|
+
return min(band, max_bps)
|
|
3316
|
+
|
|
3317
|
+
async def check_spot_depth_ok(
|
|
3318
|
+
self,
|
|
3319
|
+
spot_asset_id: int,
|
|
3320
|
+
order_usd: float,
|
|
3321
|
+
side: str,
|
|
3322
|
+
*,
|
|
3323
|
+
day_ntl_usd: float | None = None,
|
|
3324
|
+
params: dict[str, Any] | None = None,
|
|
3325
|
+
book: dict[str, Any] | None = None,
|
|
3326
|
+
fallback_mid: float | None = None,
|
|
3327
|
+
spot_symbol: str | None = None,
|
|
3328
|
+
) -> dict[str, Any]:
|
|
3329
|
+
"""
|
|
3330
|
+
Heuristic spot book depth gate using USD notionals.
|
|
3331
|
+
|
|
3332
|
+
Returns diagnostics including available depth, thresholds, and pass/fail flags.
|
|
3333
|
+
"""
|
|
3334
|
+
|
|
3335
|
+
config: dict[str, Any] = {
|
|
3336
|
+
"base_band_bps": 50,
|
|
3337
|
+
"max_band_bps": 100,
|
|
3338
|
+
"band_gamma": 20,
|
|
3339
|
+
"max_fill_ratio": 0.10,
|
|
3340
|
+
"depth_multiple": 2.0,
|
|
3341
|
+
"min_depth_floor_usd": 10_000.0,
|
|
3342
|
+
"day_frac_cap": 0.005,
|
|
3343
|
+
}
|
|
3344
|
+
if params:
|
|
3345
|
+
config.update(params)
|
|
3346
|
+
|
|
3347
|
+
try:
|
|
3348
|
+
book_snapshot = (
|
|
3349
|
+
book
|
|
3350
|
+
if book is not None
|
|
3351
|
+
else await self._l2_book_spot(
|
|
3352
|
+
spot_asset_id, fallback_mid=fallback_mid, spot_symbol=spot_symbol
|
|
3353
|
+
)
|
|
3354
|
+
)
|
|
3355
|
+
except Exception as exc: # noqa: BLE001
|
|
3356
|
+
dyn_min_depth = max(
|
|
3357
|
+
float(config["min_depth_floor_usd"]),
|
|
3358
|
+
float(config["depth_multiple"]) * float(order_usd),
|
|
3359
|
+
)
|
|
3360
|
+
return {
|
|
3361
|
+
"pass": False,
|
|
3362
|
+
"side": side,
|
|
3363
|
+
"order_usd": float(order_usd),
|
|
3364
|
+
"mid_px": 0.0,
|
|
3365
|
+
"band_bps": int(config["base_band_bps"]),
|
|
3366
|
+
"depth_side_usd": 0.0,
|
|
3367
|
+
"max_fill_ratio": float(config["max_fill_ratio"]),
|
|
3368
|
+
"depth_multiple": float(config["depth_multiple"]),
|
|
3369
|
+
"min_depth_floor_usd": float(config["min_depth_floor_usd"]),
|
|
3370
|
+
"dyn_min_depth_usd": float(dyn_min_depth),
|
|
3371
|
+
"max_allowed_by_depth": 0.0,
|
|
3372
|
+
"day_ntl_usd": day_ntl_usd,
|
|
3373
|
+
"day_frac_cap": float(config["day_frac_cap"]),
|
|
3374
|
+
"max_allowed_by_turnover": None,
|
|
3375
|
+
"reasons": [
|
|
3376
|
+
f"failed to fetch L2 book for spot_asset_id {spot_asset_id}: {exc}"
|
|
3377
|
+
],
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
band_bps = self._depth_band_for_size(
|
|
3381
|
+
order_usd,
|
|
3382
|
+
base_bps=int(config["base_band_bps"]),
|
|
3383
|
+
max_bps=int(config["max_band_bps"]),
|
|
3384
|
+
gamma=int(config["band_gamma"]),
|
|
3385
|
+
)
|
|
3386
|
+
|
|
3387
|
+
depth_side_usd, mid = self._usd_depth_in_band(book_snapshot, band_bps, side)
|
|
3388
|
+
|
|
3389
|
+
dyn_min_depth = max(
|
|
3390
|
+
float(config["min_depth_floor_usd"]),
|
|
3391
|
+
float(config["depth_multiple"]) * float(order_usd),
|
|
3392
|
+
)
|
|
3393
|
+
|
|
3394
|
+
max_allowed_by_depth = float(config["max_fill_ratio"]) * float(depth_side_usd)
|
|
3395
|
+
depth_ok = (
|
|
3396
|
+
float(depth_side_usd) >= dyn_min_depth
|
|
3397
|
+
and float(order_usd) <= max_allowed_by_depth
|
|
3398
|
+
and float(depth_side_usd) > 0.0
|
|
3399
|
+
)
|
|
3400
|
+
|
|
3401
|
+
turnover_ok = True
|
|
3402
|
+
max_allowed_by_turnover: float | None = None
|
|
3403
|
+
if day_ntl_usd is not None and day_ntl_usd > 0:
|
|
3404
|
+
max_allowed_by_turnover = float(config["day_frac_cap"]) * float(day_ntl_usd)
|
|
3405
|
+
turnover_ok = float(order_usd) <= max_allowed_by_turnover
|
|
3406
|
+
|
|
3407
|
+
reasons: list[str] = []
|
|
3408
|
+
if float(depth_side_usd) < dyn_min_depth:
|
|
3409
|
+
reasons.append(
|
|
3410
|
+
f"insufficient book depth in band (need ≥ {dyn_min_depth:,.2f})"
|
|
3411
|
+
)
|
|
3412
|
+
if float(order_usd) > max_allowed_by_depth:
|
|
3413
|
+
reasons.append("order size exceeds depth-based cap")
|
|
3414
|
+
if not turnover_ok:
|
|
3415
|
+
reasons.append("exceeds daily turnover cap")
|
|
3416
|
+
|
|
3417
|
+
return {
|
|
3418
|
+
"pass": bool(depth_ok and turnover_ok),
|
|
3419
|
+
"side": side,
|
|
3420
|
+
"order_usd": float(order_usd),
|
|
3421
|
+
"mid_px": float(mid),
|
|
3422
|
+
"band_bps": int(band_bps),
|
|
3423
|
+
"depth_side_usd": float(depth_side_usd),
|
|
3424
|
+
"depth_multiple": float(config["depth_multiple"]),
|
|
3425
|
+
"min_depth_floor_usd": float(config["min_depth_floor_usd"]),
|
|
3426
|
+
"dyn_min_depth_usd": float(dyn_min_depth),
|
|
3427
|
+
"max_fill_ratio": float(config["max_fill_ratio"]),
|
|
3428
|
+
"max_allowed_by_depth": float(max_allowed_by_depth),
|
|
3429
|
+
"day_ntl_usd": day_ntl_usd,
|
|
3430
|
+
"day_frac_cap": float(config["day_frac_cap"]),
|
|
3431
|
+
"max_allowed_by_turnover": max_allowed_by_turnover,
|
|
3432
|
+
"reasons": reasons,
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
def _estimate_spot_slippage_usd(
|
|
3436
|
+
self,
|
|
3437
|
+
book: dict[str, Any],
|
|
3438
|
+
order_usd: float,
|
|
3439
|
+
side: str,
|
|
3440
|
+
band_bps: int,
|
|
3441
|
+
) -> float:
|
|
3442
|
+
depth_usd, _mid = self._usd_depth_in_band(book, band_bps, side)
|
|
3443
|
+
if order_usd <= 0 or depth_usd <= 0:
|
|
3444
|
+
return 0.0
|
|
3445
|
+
fill_fraction = min(1.0, order_usd / depth_usd)
|
|
3446
|
+
return fill_fraction * (band_bps * 0.5 / 1e4) * order_usd
|
|
3447
|
+
|
|
3448
|
+
async def _estimate_cycle_costs(
|
|
3449
|
+
self,
|
|
3450
|
+
*,
|
|
3451
|
+
N_leg_usd: float,
|
|
3452
|
+
spot_asset_id: int,
|
|
3453
|
+
spot_book: dict[str, Any],
|
|
3454
|
+
fee_model: dict[str, float] | None = None,
|
|
3455
|
+
depth_params: dict[str, Any] | None = None,
|
|
3456
|
+
perp_slippage_bps: float = 1.0,
|
|
3457
|
+
day_ntl_usd: float | None = None,
|
|
3458
|
+
spot_symbol: str | None = None,
|
|
3459
|
+
) -> tuple[float, float, dict[str, float], dict[str, dict[str, Any]]]:
|
|
3460
|
+
"""Estimate entry/exit execution costs for a full cycle on both legs."""
|
|
3461
|
+
|
|
3462
|
+
cfg_fees = {"spot_bps": 9.0, "perp_bps": 6.0}
|
|
3463
|
+
if fee_model:
|
|
3464
|
+
cfg_fees.update(fee_model)
|
|
3465
|
+
|
|
3466
|
+
buy_chk = await self.check_spot_depth_ok(
|
|
3467
|
+
spot_asset_id,
|
|
3468
|
+
N_leg_usd,
|
|
3469
|
+
"buy",
|
|
3470
|
+
day_ntl_usd=day_ntl_usd,
|
|
3471
|
+
params=depth_params,
|
|
3472
|
+
book=spot_book,
|
|
3473
|
+
spot_symbol=spot_symbol,
|
|
3474
|
+
)
|
|
3475
|
+
sell_chk = await self.check_spot_depth_ok(
|
|
3476
|
+
spot_asset_id,
|
|
3477
|
+
N_leg_usd,
|
|
3478
|
+
"sell",
|
|
3479
|
+
day_ntl_usd=day_ntl_usd,
|
|
3480
|
+
params=depth_params,
|
|
3481
|
+
book=spot_book,
|
|
3482
|
+
spot_symbol=spot_symbol,
|
|
3483
|
+
)
|
|
3484
|
+
|
|
3485
|
+
band_buy = int(buy_chk.get("band_bps", 50))
|
|
3486
|
+
band_sell = int(sell_chk.get("band_bps", 50))
|
|
3487
|
+
|
|
3488
|
+
spot_slip_entry = 0.5 * (
|
|
3489
|
+
self._estimate_spot_slippage_usd(spot_book, N_leg_usd, "buy", band_buy)
|
|
3490
|
+
+ self._estimate_spot_slippage_usd(spot_book, N_leg_usd, "sell", band_sell)
|
|
3491
|
+
)
|
|
3492
|
+
spot_slip_exit = spot_slip_entry
|
|
3493
|
+
|
|
3494
|
+
spot_fee_entry = (cfg_fees["spot_bps"] / 1e4) * N_leg_usd
|
|
3495
|
+
spot_fee_exit = (cfg_fees["spot_bps"] / 1e4) * N_leg_usd
|
|
3496
|
+
perp_fee_entry = (cfg_fees["perp_bps"] / 1e4) * N_leg_usd
|
|
3497
|
+
perp_fee_exit = (cfg_fees["perp_bps"] / 1e4) * N_leg_usd
|
|
3498
|
+
|
|
3499
|
+
perp_slip_entry = (perp_slippage_bps / 1e4) * N_leg_usd
|
|
3500
|
+
perp_slip_exit = (perp_slippage_bps / 1e4) * N_leg_usd
|
|
3501
|
+
|
|
3502
|
+
entry_cost = spot_slip_entry + spot_fee_entry + perp_slip_entry + perp_fee_entry
|
|
3503
|
+
exit_cost = spot_slip_exit + spot_fee_exit + perp_slip_exit + perp_fee_exit
|
|
3504
|
+
|
|
3505
|
+
breakdown = {
|
|
3506
|
+
"spot_slip_entry": spot_slip_entry,
|
|
3507
|
+
"spot_slip_exit": spot_slip_exit,
|
|
3508
|
+
"spot_fee_entry": spot_fee_entry,
|
|
3509
|
+
"spot_fee_exit": spot_fee_exit,
|
|
3510
|
+
"perp_slip_entry": perp_slip_entry,
|
|
3511
|
+
"perp_slip_exit": perp_slip_exit,
|
|
3512
|
+
"perp_fee_entry": perp_fee_entry,
|
|
3513
|
+
"perp_fee_exit": perp_fee_exit,
|
|
3514
|
+
"band_bps_buy": float(band_buy),
|
|
3515
|
+
"band_bps_sell": float(band_sell),
|
|
3516
|
+
"depth_usd_buy": float(buy_chk.get("depth_side_usd", 0.0)),
|
|
3517
|
+
"depth_usd_sell": float(sell_chk.get("depth_side_usd", 0.0)),
|
|
3518
|
+
}
|
|
3519
|
+
return entry_cost, exit_cost, breakdown, {"buy": buy_chk, "sell": sell_chk}
|
|
3520
|
+
|
|
3521
|
+
async def _get_margin_table_tiers(self, table_id: int) -> list[dict[str, float]]:
|
|
3522
|
+
"""Fetch and cache margin table tiers with maintenance rates and deductions."""
|
|
3523
|
+
if table_id in self._margin_table_cache:
|
|
3524
|
+
return [dict(t) for t in self._margin_table_cache[table_id]]
|
|
3525
|
+
|
|
3526
|
+
if not hasattr(self.hyperliquid_adapter, "get_margin_table"):
|
|
3527
|
+
self._margin_table_cache[table_id] = []
|
|
3528
|
+
return []
|
|
3529
|
+
|
|
3530
|
+
try:
|
|
3531
|
+
success, response = await self.hyperliquid_adapter.get_margin_table(
|
|
3532
|
+
int(table_id)
|
|
3533
|
+
)
|
|
3534
|
+
except Exception as exc: # noqa: BLE001
|
|
3535
|
+
self.logger.warning(f"Failed to fetch margin table {table_id}: {exc}")
|
|
3536
|
+
self._margin_table_cache[table_id] = []
|
|
3537
|
+
return []
|
|
3538
|
+
|
|
3539
|
+
if not success or not isinstance(response, dict):
|
|
3540
|
+
self._margin_table_cache[table_id] = []
|
|
3541
|
+
return []
|
|
3542
|
+
|
|
3543
|
+
tiers_raw = response.get("marginTiers") or []
|
|
3544
|
+
tiers_sorted = sorted(
|
|
3545
|
+
(
|
|
3546
|
+
{
|
|
3547
|
+
"lowerBound": float(tier.get("lowerBound", 0.0) or 0.0),
|
|
3548
|
+
"maxLeverage": float(tier.get("maxLeverage", 0.0) or 0.0),
|
|
3549
|
+
}
|
|
3550
|
+
for tier in tiers_raw
|
|
3551
|
+
if isinstance(tier, dict)
|
|
3552
|
+
),
|
|
3553
|
+
key=lambda t: t["lowerBound"],
|
|
3554
|
+
)
|
|
3555
|
+
|
|
3556
|
+
processed: list[dict[str, float]] = []
|
|
3557
|
+
deduction = 0.0
|
|
3558
|
+
prev_rate: float | None = None
|
|
3559
|
+
|
|
3560
|
+
for tier in tiers_sorted:
|
|
3561
|
+
lower = max(0.0, tier["lowerBound"])
|
|
3562
|
+
max_lev = tier["maxLeverage"]
|
|
3563
|
+
if max_lev <= 0.0:
|
|
3564
|
+
continue
|
|
3565
|
+
|
|
3566
|
+
maint_rate = 1.0 / (2.0 * max_lev)
|
|
3567
|
+
if prev_rate is not None:
|
|
3568
|
+
deduction += lower * (maint_rate - prev_rate)
|
|
3569
|
+
processed.append(
|
|
3570
|
+
{
|
|
3571
|
+
"lower_bound": float(lower),
|
|
3572
|
+
"maint_rate": float(maint_rate),
|
|
3573
|
+
"deduction": float(deduction),
|
|
3574
|
+
}
|
|
3575
|
+
)
|
|
3576
|
+
prev_rate = maint_rate
|
|
3577
|
+
|
|
3578
|
+
self._margin_table_cache[table_id] = [dict(t) for t in processed]
|
|
3579
|
+
return [dict(t) for t in processed]
|
|
3580
|
+
|
|
3581
|
+
def maintenance_fraction_for_notional(
|
|
3582
|
+
self,
|
|
3583
|
+
margin_table_id: int | None,
|
|
3584
|
+
notional_usd: float,
|
|
3585
|
+
fallback_max_leverage: int,
|
|
3586
|
+
) -> float:
|
|
3587
|
+
"""Return maintenance margin fraction for a given notional, honoring tiered tables."""
|
|
3588
|
+
fallback_mmr = self.maintenance_rate_from_max_leverage(
|
|
3589
|
+
max(1, int(fallback_max_leverage))
|
|
3590
|
+
)
|
|
3591
|
+
notional = float(notional_usd)
|
|
3592
|
+
if notional <= 0 or not margin_table_id:
|
|
3593
|
+
return fallback_mmr
|
|
3594
|
+
|
|
3595
|
+
tiers = self._margin_table_cache.get(int(margin_table_id)) or []
|
|
3596
|
+
if not tiers:
|
|
3597
|
+
return fallback_mmr
|
|
3598
|
+
|
|
3599
|
+
chosen = tiers[0]
|
|
3600
|
+
for tier in tiers:
|
|
3601
|
+
if notional >= float(tier["lower_bound"]):
|
|
3602
|
+
chosen = tier
|
|
3603
|
+
else:
|
|
3604
|
+
break
|
|
3605
|
+
|
|
3606
|
+
maint_rate = float(chosen["maint_rate"])
|
|
3607
|
+
deduction = float(chosen["deduction"])
|
|
3608
|
+
maintenance_margin = maint_rate * notional - deduction
|
|
3609
|
+
if maintenance_margin <= 0:
|
|
3610
|
+
return max(maint_rate, fallback_mmr)
|
|
3611
|
+
|
|
3612
|
+
fraction = maintenance_margin / notional
|
|
3613
|
+
return max(min(float(fraction), 1.0), 0.0)
|
|
3614
|
+
|
|
3615
|
+
def _first_stop_horizon(
|
|
3616
|
+
self,
|
|
3617
|
+
*,
|
|
3618
|
+
start_idx: int,
|
|
3619
|
+
closes: list[float],
|
|
3620
|
+
highs: list[float],
|
|
3621
|
+
hourly_funding: list[float],
|
|
3622
|
+
leverage: int,
|
|
3623
|
+
stop_frac: float,
|
|
3624
|
+
fee_eps: float,
|
|
3625
|
+
maintenance_fn,
|
|
3626
|
+
base_notional: float,
|
|
3627
|
+
) -> int:
|
|
3628
|
+
"""Return the forward hours until the stop barrier is hit or data is exhausted."""
|
|
3629
|
+
n = min(len(closes), len(highs), len(hourly_funding)) - 1
|
|
3630
|
+
if start_idx >= n:
|
|
3631
|
+
return 0
|
|
3632
|
+
|
|
3633
|
+
entry = closes[start_idx]
|
|
3634
|
+
if entry <= 0:
|
|
3635
|
+
return 1
|
|
3636
|
+
|
|
3637
|
+
peak = entry
|
|
3638
|
+
cum_neg_f = 0.0
|
|
3639
|
+
max_j = n - start_idx
|
|
3640
|
+
|
|
3641
|
+
if not (0.0 < stop_frac <= 1.0):
|
|
3642
|
+
raise ValueError(f"stop_frac must be in (0, 1], got {stop_frac}")
|
|
3643
|
+
|
|
3644
|
+
L = max(1, int(leverage))
|
|
3645
|
+
threshold = stop_frac * (1.0 / float(L))
|
|
3646
|
+
|
|
3647
|
+
for j in range(1, max_j + 1):
|
|
3648
|
+
idx = start_idx + j
|
|
3649
|
+
h = highs[idx]
|
|
3650
|
+
if h > peak:
|
|
3651
|
+
peak = h
|
|
3652
|
+
|
|
3653
|
+
runup = (peak / entry) - 1.0
|
|
3654
|
+
r = hourly_funding[idx]
|
|
3655
|
+
if r < 0.0:
|
|
3656
|
+
cum_neg_f += (-r) * (1.0 + runup)
|
|
3657
|
+
|
|
3658
|
+
notional = base_notional * (1.0 + runup)
|
|
3659
|
+
maintenance_fraction = float(maintenance_fn(notional))
|
|
3660
|
+
req = maintenance_fraction * (1.0 + runup) + runup + cum_neg_f + fee_eps
|
|
3661
|
+
if req >= threshold:
|
|
3662
|
+
return j
|
|
3663
|
+
|
|
3664
|
+
return max_j
|
|
3665
|
+
|
|
3666
|
+
def _simulate_barrier_backtest(
|
|
3667
|
+
self,
|
|
3668
|
+
*,
|
|
3669
|
+
funding: list[float],
|
|
3670
|
+
closes: list[float],
|
|
3671
|
+
highs: list[float],
|
|
3672
|
+
leverage: int,
|
|
3673
|
+
stop_frac: float,
|
|
3674
|
+
fee_eps: float,
|
|
3675
|
+
N_leg_usd: float,
|
|
3676
|
+
entry_cost_usd: float,
|
|
3677
|
+
exit_cost_usd: float,
|
|
3678
|
+
margin_table_id: int | None,
|
|
3679
|
+
fallback_max_leverage: int,
|
|
3680
|
+
cooloff_hours: int = 0,
|
|
3681
|
+
) -> dict[str, float]:
|
|
3682
|
+
"""Simulate repeated entries/exits under a stop barrier and accumulate PnL."""
|
|
3683
|
+
n = min(len(funding), len(closes), len(highs)) - 1
|
|
3684
|
+
if n <= 0:
|
|
3685
|
+
return {
|
|
3686
|
+
"net_pnl_usd": 0.0,
|
|
3687
|
+
"gross_funding_usd": 0.0,
|
|
3688
|
+
"cycles": 0,
|
|
3689
|
+
"hours": 0,
|
|
3690
|
+
"hours_in_market": 0,
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
pnl = 0.0
|
|
3694
|
+
gross_funding = 0.0
|
|
3695
|
+
cycles = 0
|
|
3696
|
+
t = 0
|
|
3697
|
+
hours_in_market = 0
|
|
3698
|
+
|
|
3699
|
+
def maintenance_fn(notional: float) -> float:
|
|
3700
|
+
return self.maintenance_fraction_for_notional(
|
|
3701
|
+
margin_table_id,
|
|
3702
|
+
notional,
|
|
3703
|
+
fallback_max_leverage,
|
|
3704
|
+
)
|
|
3705
|
+
|
|
3706
|
+
while t < n:
|
|
3707
|
+
pnl -= entry_cost_usd
|
|
3708
|
+
cycles += 1
|
|
3709
|
+
|
|
3710
|
+
j = self._first_stop_horizon(
|
|
3711
|
+
start_idx=t,
|
|
3712
|
+
closes=closes,
|
|
3713
|
+
highs=highs,
|
|
3714
|
+
hourly_funding=funding,
|
|
3715
|
+
leverage=leverage,
|
|
3716
|
+
stop_frac=stop_frac,
|
|
3717
|
+
fee_eps=fee_eps,
|
|
3718
|
+
maintenance_fn=maintenance_fn,
|
|
3719
|
+
base_notional=N_leg_usd,
|
|
3720
|
+
)
|
|
3721
|
+
j = max(1, min(j, n - t))
|
|
3722
|
+
|
|
3723
|
+
entry_px = closes[t] if 0 <= t < len(closes) else 0.0
|
|
3724
|
+
funding_sum = 0.0
|
|
3725
|
+
for k in range(1, j + 1):
|
|
3726
|
+
idx = t + k
|
|
3727
|
+
funding_rate = funding[idx] if idx < len(funding) else 0.0
|
|
3728
|
+
if entry_px > 0:
|
|
3729
|
+
px = closes[idx] if idx < len(closes) else entry_px
|
|
3730
|
+
px_ratio = px / entry_px
|
|
3731
|
+
else:
|
|
3732
|
+
px_ratio = 1.0
|
|
3733
|
+
funding_sum += funding_rate * px_ratio
|
|
3734
|
+
|
|
3735
|
+
funding_usd = N_leg_usd * funding_sum
|
|
3736
|
+
pnl += funding_usd
|
|
3737
|
+
gross_funding += funding_usd
|
|
3738
|
+
hours_in_market += j
|
|
3739
|
+
|
|
3740
|
+
t += j
|
|
3741
|
+
if t >= n:
|
|
3742
|
+
break
|
|
3743
|
+
|
|
3744
|
+
pnl -= exit_cost_usd
|
|
3745
|
+
if cooloff_hours > 0:
|
|
3746
|
+
t += cooloff_hours
|
|
3747
|
+
|
|
3748
|
+
return {
|
|
3749
|
+
"net_pnl_usd": float(pnl),
|
|
3750
|
+
"gross_funding_usd": float(gross_funding),
|
|
3751
|
+
"cycles": float(cycles),
|
|
3752
|
+
"hours": float(n),
|
|
3753
|
+
"hours_in_market": float(hours_in_market),
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
@staticmethod
|
|
3757
|
+
def _percentile(sorted_values: list[float], pct: float) -> float:
|
|
3758
|
+
"""Inclusive percentile on a pre-sorted list."""
|
|
3759
|
+
return analytics_percentile(sorted_values, pct)
|
|
3760
|
+
|
|
3761
|
+
def _block_bootstrap_paths(
|
|
3762
|
+
self,
|
|
3763
|
+
*,
|
|
3764
|
+
funding: list[float],
|
|
3765
|
+
closes: list[float],
|
|
3766
|
+
highs: list[float],
|
|
3767
|
+
block_hours: int,
|
|
3768
|
+
sims: int,
|
|
3769
|
+
rng: random.Random,
|
|
3770
|
+
) -> list[tuple[list[float], list[float], list[float]]]:
|
|
3771
|
+
"""Return block-bootstrap resampled series for funding/close/high paths."""
|
|
3772
|
+
paths = analytics_block_bootstrap_paths(
|
|
3773
|
+
funding,
|
|
3774
|
+
closes,
|
|
3775
|
+
highs,
|
|
3776
|
+
block_hours=block_hours,
|
|
3777
|
+
sims=sims,
|
|
3778
|
+
rng=rng,
|
|
3779
|
+
)
|
|
3780
|
+
return [(f, c, h) for (f, c, h) in paths]
|
|
3781
|
+
|
|
3782
|
+
def _bootstrap_churn_metrics(
|
|
3783
|
+
self,
|
|
3784
|
+
*,
|
|
3785
|
+
funding: list[float],
|
|
3786
|
+
closes: list[float],
|
|
3787
|
+
highs: list[float],
|
|
3788
|
+
leverage: int,
|
|
3789
|
+
stop_frac: float,
|
|
3790
|
+
fee_eps: float,
|
|
3791
|
+
N_leg_usd: float,
|
|
3792
|
+
entry_cost_usd: float,
|
|
3793
|
+
exit_cost_usd: float,
|
|
3794
|
+
margin_table_id: int | None,
|
|
3795
|
+
fallback_max_leverage: int,
|
|
3796
|
+
cooloff_hours: int,
|
|
3797
|
+
deposit_usdc: float,
|
|
3798
|
+
sims: int,
|
|
3799
|
+
block_hours: int,
|
|
3800
|
+
seed: int | None,
|
|
3801
|
+
) -> dict[str, Any] | None:
|
|
3802
|
+
"""Run block-bootstrap replays and summarize churn metrics."""
|
|
3803
|
+
if sims <= 0 or deposit_usdc <= 0:
|
|
3804
|
+
return None
|
|
3805
|
+
|
|
3806
|
+
base_len = min(len(funding), len(closes), len(highs))
|
|
3807
|
+
if base_len <= 1:
|
|
3808
|
+
return None
|
|
3809
|
+
|
|
3810
|
+
rng_seed = seed if seed is not None else random.randrange(1 << 30)
|
|
3811
|
+
rng = random.Random(rng_seed)
|
|
3812
|
+
|
|
3813
|
+
paths = self._block_bootstrap_paths(
|
|
3814
|
+
funding=funding,
|
|
3815
|
+
closes=closes,
|
|
3816
|
+
highs=highs,
|
|
3817
|
+
block_hours=block_hours,
|
|
3818
|
+
sims=sims,
|
|
3819
|
+
rng=rng,
|
|
3820
|
+
)
|
|
3821
|
+
if not paths:
|
|
3822
|
+
return None
|
|
3823
|
+
|
|
3824
|
+
net_apy_samples: list[float] = []
|
|
3825
|
+
gross_apy_samples: list[float] = []
|
|
3826
|
+
time_in_market_samples: list[float] = []
|
|
3827
|
+
hit_rate_samples: list[float] = []
|
|
3828
|
+
avg_hold_samples: list[float] = []
|
|
3829
|
+
cycles_samples: list[float] = []
|
|
3830
|
+
|
|
3831
|
+
for f_boot, c_boot, h_boot in paths:
|
|
3832
|
+
sim_res = self._simulate_barrier_backtest(
|
|
3833
|
+
funding=f_boot,
|
|
3834
|
+
closes=c_boot,
|
|
3835
|
+
highs=h_boot,
|
|
3836
|
+
leverage=leverage,
|
|
3837
|
+
stop_frac=stop_frac,
|
|
3838
|
+
fee_eps=fee_eps,
|
|
3839
|
+
N_leg_usd=N_leg_usd,
|
|
3840
|
+
entry_cost_usd=entry_cost_usd,
|
|
3841
|
+
exit_cost_usd=exit_cost_usd,
|
|
3842
|
+
margin_table_id=margin_table_id,
|
|
3843
|
+
fallback_max_leverage=fallback_max_leverage,
|
|
3844
|
+
cooloff_hours=cooloff_hours,
|
|
3845
|
+
)
|
|
3846
|
+
|
|
3847
|
+
hours = max(1.0, float(sim_res["hours"]))
|
|
3848
|
+
years = hours / (24.0 * 365.0)
|
|
3849
|
+
net_apy = (float(sim_res["net_pnl_usd"]) / max(1e-9, deposit_usdc)) / years
|
|
3850
|
+
gross_apy = (
|
|
3851
|
+
float(sim_res["gross_funding_usd"]) / max(1e-9, deposit_usdc)
|
|
3852
|
+
) / years
|
|
3853
|
+
hit_rate_per_day = (
|
|
3854
|
+
float(sim_res["cycles"]) / (hours / 24.0) if hours > 0 else 0.0
|
|
3855
|
+
)
|
|
3856
|
+
avg_hold_hours = (
|
|
3857
|
+
float(sim_res["hours_in_market"]) / max(1.0, float(sim_res["cycles"]))
|
|
3858
|
+
if float(sim_res["cycles"]) > 0
|
|
3859
|
+
else hours
|
|
3860
|
+
)
|
|
3861
|
+
time_in_market = float(sim_res["hours_in_market"]) / hours
|
|
3862
|
+
|
|
3863
|
+
net_apy_samples.append(net_apy)
|
|
3864
|
+
gross_apy_samples.append(gross_apy)
|
|
3865
|
+
time_in_market_samples.append(time_in_market)
|
|
3866
|
+
hit_rate_samples.append(hit_rate_per_day)
|
|
3867
|
+
avg_hold_samples.append(avg_hold_hours)
|
|
3868
|
+
cycles_samples.append(float(sim_res["cycles"]))
|
|
3869
|
+
|
|
3870
|
+
if not net_apy_samples:
|
|
3871
|
+
return None
|
|
3872
|
+
|
|
3873
|
+
def summarize(values: list[float]) -> dict[str, float]:
|
|
3874
|
+
ordered = sorted(values)
|
|
3875
|
+
return {
|
|
3876
|
+
"mean": float(fmean(ordered)),
|
|
3877
|
+
"p05": self._percentile(ordered, 0.05),
|
|
3878
|
+
"p25": self._percentile(ordered, 0.25),
|
|
3879
|
+
"p50": self._percentile(ordered, 0.50),
|
|
3880
|
+
"p75": self._percentile(ordered, 0.75),
|
|
3881
|
+
"p95": self._percentile(ordered, 0.95),
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
return {
|
|
3885
|
+
"samples": len(net_apy_samples),
|
|
3886
|
+
"block_hours": int(block_hours),
|
|
3887
|
+
"seed": int(rng_seed),
|
|
3888
|
+
"net_apy": summarize(net_apy_samples),
|
|
3889
|
+
"gross_funding_apy": summarize(gross_apy_samples),
|
|
3890
|
+
"time_in_market_frac": summarize(time_in_market_samples),
|
|
3891
|
+
"hit_rate_per_day": summarize(hit_rate_samples),
|
|
3892
|
+
"avg_hold_hours": summarize(avg_hold_samples),
|
|
3893
|
+
"cycles": summarize(cycles_samples),
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
def _buffer_requirement_tiered(
|
|
3897
|
+
self,
|
|
3898
|
+
*,
|
|
3899
|
+
closes: list[float],
|
|
3900
|
+
highs: list[float],
|
|
3901
|
+
hourly_funding: list[float],
|
|
3902
|
+
window: int,
|
|
3903
|
+
margin_table_id: int | None,
|
|
3904
|
+
base_notional: float,
|
|
3905
|
+
fallback_max_leverage: int,
|
|
3906
|
+
fee_eps: float,
|
|
3907
|
+
require_full_window: bool = True,
|
|
3908
|
+
) -> float:
|
|
3909
|
+
"""Worst-case buffer requirement accounting for tiered maintenance margin."""
|
|
3910
|
+
|
|
3911
|
+
fallback_mmr = self.maintenance_rate_from_max_leverage(
|
|
3912
|
+
max(1, int(fallback_max_leverage))
|
|
3913
|
+
)
|
|
3914
|
+
if base_notional <= 0:
|
|
3915
|
+
return float(fallback_mmr + fee_eps)
|
|
3916
|
+
|
|
3917
|
+
n = min(len(closes), len(highs), len(hourly_funding))
|
|
3918
|
+
if n == 0 or window <= 0:
|
|
3919
|
+
return float(fallback_mmr + fee_eps)
|
|
3920
|
+
|
|
3921
|
+
i_max = (n - 1 - window) if require_full_window else (n - 2)
|
|
3922
|
+
if i_max < 0:
|
|
3923
|
+
return float(fallback_mmr + fee_eps)
|
|
3924
|
+
|
|
3925
|
+
worst_req = 0.0
|
|
3926
|
+
|
|
3927
|
+
for i in range(0, i_max + 1):
|
|
3928
|
+
entry = closes[i]
|
|
3929
|
+
if entry <= 0:
|
|
3930
|
+
continue
|
|
3931
|
+
|
|
3932
|
+
peak = entry
|
|
3933
|
+
cum_f = 0.0
|
|
3934
|
+
|
|
3935
|
+
for j in range(1, window + 1):
|
|
3936
|
+
idx = i + j
|
|
3937
|
+
h = highs[idx]
|
|
3938
|
+
if h > peak:
|
|
3939
|
+
peak = h
|
|
3940
|
+
|
|
3941
|
+
runup = (peak / entry) - 1.0
|
|
3942
|
+
r = hourly_funding[idx]
|
|
3943
|
+
if r < 0.0:
|
|
3944
|
+
cum_f += (-r) * (1.0 + runup)
|
|
3945
|
+
|
|
3946
|
+
notional = base_notional * (1.0 + runup)
|
|
3947
|
+
maintenance_fraction = self.maintenance_fraction_for_notional(
|
|
3948
|
+
margin_table_id,
|
|
3949
|
+
notional,
|
|
3950
|
+
fallback_max_leverage,
|
|
3951
|
+
)
|
|
3952
|
+
req = maintenance_fraction * (1.0 + runup) + runup + cum_f + fee_eps
|
|
3953
|
+
if req > worst_req:
|
|
3954
|
+
worst_req = req
|
|
3955
|
+
|
|
3956
|
+
return worst_req if worst_req > 0 else float(fallback_mmr + fee_eps)
|
|
3957
|
+
|
|
3958
|
+
def get_sz_decimals_for_hypecore_asset(self, asset_id: int) -> int:
|
|
3959
|
+
try:
|
|
3960
|
+
mapping = self.hyperliquid_adapter.asset_to_sz_decimals
|
|
3961
|
+
except Exception as exc: # noqa: BLE001
|
|
3962
|
+
raise ValueError("Hyperliquid asset_to_sz_decimals not available") from exc
|
|
3963
|
+
|
|
3964
|
+
if not isinstance(mapping, dict):
|
|
3965
|
+
raise ValueError(f"Unknown asset_id {asset_id}: missing szDecimals")
|
|
3966
|
+
return hl_sz_decimals_for_asset(mapping, asset_id)
|
|
3967
|
+
|
|
3968
|
+
def _size_step(self, asset_id: int) -> Decimal:
|
|
3969
|
+
try:
|
|
3970
|
+
mapping = self.hyperliquid_adapter.asset_to_sz_decimals
|
|
3971
|
+
except Exception as exc: # noqa: BLE001
|
|
3972
|
+
raise ValueError("Hyperliquid asset_to_sz_decimals not available") from exc
|
|
3973
|
+
|
|
3974
|
+
if not isinstance(mapping, dict):
|
|
3975
|
+
raise ValueError(f"Unknown asset_id {asset_id}: missing szDecimals")
|
|
3976
|
+
return hl_size_step(mapping, asset_id)
|
|
3977
|
+
|
|
3978
|
+
def round_size_for_hypecore_asset(
|
|
3979
|
+
self, asset_id: int, size: float | Decimal, *, ensure_min_step: bool = False
|
|
3980
|
+
) -> float:
|
|
3981
|
+
"""Floor to step using Decimal to avoid float issues."""
|
|
3982
|
+
try:
|
|
3983
|
+
mapping = self.hyperliquid_adapter.asset_to_sz_decimals
|
|
3984
|
+
except Exception as exc: # noqa: BLE001
|
|
3985
|
+
raise ValueError("Hyperliquid asset_to_sz_decimals not available") from exc
|
|
3986
|
+
|
|
3987
|
+
if not isinstance(mapping, dict):
|
|
3988
|
+
raise ValueError(f"Unknown asset_id {asset_id}: missing szDecimals")
|
|
3989
|
+
return hl_round_size_for_asset(
|
|
3990
|
+
mapping, asset_id, size, ensure_min_step=ensure_min_step
|
|
3991
|
+
)
|
|
3992
|
+
|
|
3993
|
+
def _common_unit_step(
|
|
3994
|
+
self, spot_asset_id: int, perp_asset_id: int | None
|
|
3995
|
+
) -> Decimal:
|
|
3996
|
+
step_spot = self._size_step(spot_asset_id)
|
|
3997
|
+
step_perp = (
|
|
3998
|
+
self._size_step(perp_asset_id) if perp_asset_id is not None else step_spot
|
|
3999
|
+
)
|
|
4000
|
+
return max(step_spot, step_perp)
|
|
4001
|
+
|
|
4002
|
+
def _min_deposit_needed(
|
|
4003
|
+
self,
|
|
4004
|
+
*,
|
|
4005
|
+
mark_price: float | Decimal,
|
|
4006
|
+
leverage: int,
|
|
4007
|
+
spot_asset_id: int,
|
|
4008
|
+
perp_asset_id: int | None,
|
|
4009
|
+
) -> float:
|
|
4010
|
+
"""
|
|
4011
|
+
Minimum USDC deposit to place at least one lot on both legs at leverage L.
|
|
4012
|
+
|
|
4013
|
+
D_min(L) = N * (1 + 1/L), with N = unit_step * mark_px.
|
|
4014
|
+
"""
|
|
4015
|
+
L = max(1, int(leverage))
|
|
4016
|
+
unit_step = self._common_unit_step(spot_asset_id, perp_asset_id)
|
|
4017
|
+
mark = _d(mark_price)
|
|
4018
|
+
N = unit_step * mark
|
|
4019
|
+
Dmin = N * (_d(1) + (_d(1) / _d(L)))
|
|
4020
|
+
return float(Dmin)
|
|
4021
|
+
|
|
4022
|
+
def _depth_upper_bound_usd(
|
|
4023
|
+
self,
|
|
4024
|
+
*,
|
|
4025
|
+
book: dict[str, Any],
|
|
4026
|
+
side: str,
|
|
4027
|
+
day_ntl_usd: float | None,
|
|
4028
|
+
params: dict[str, Any] | None,
|
|
4029
|
+
) -> float:
|
|
4030
|
+
"""
|
|
4031
|
+
Conservative upper bound for order size that could ever pass depth checks.
|
|
4032
|
+
|
|
4033
|
+
Uses depth at max band and turnover cap (if provided).
|
|
4034
|
+
"""
|
|
4035
|
+
config: dict[str, Any] = {
|
|
4036
|
+
"max_band_bps": 100,
|
|
4037
|
+
"max_fill_ratio": 0.10,
|
|
4038
|
+
"depth_multiple": 2.0,
|
|
4039
|
+
"min_depth_floor_usd": 10_000.0,
|
|
4040
|
+
"day_frac_cap": 0.005,
|
|
4041
|
+
}
|
|
4042
|
+
if params:
|
|
4043
|
+
config.update(params)
|
|
4044
|
+
|
|
4045
|
+
max_band = int(config["max_band_bps"])
|
|
4046
|
+
depth_side_usd, _mid = self._usd_depth_in_band(book, max_band, side)
|
|
4047
|
+
|
|
4048
|
+
if depth_side_usd <= 0.0 or depth_side_usd < float(
|
|
4049
|
+
config["min_depth_floor_usd"]
|
|
4050
|
+
):
|
|
4051
|
+
return 0.0
|
|
4052
|
+
|
|
4053
|
+
cap_depth = min(
|
|
4054
|
+
float(config["max_fill_ratio"]) * float(depth_side_usd),
|
|
4055
|
+
float(depth_side_usd) / max(1e-9, float(config["depth_multiple"])),
|
|
4056
|
+
)
|
|
4057
|
+
cap_turnover = (
|
|
4058
|
+
float("inf")
|
|
4059
|
+
if day_ntl_usd is None or float(day_ntl_usd) <= 0.0
|
|
4060
|
+
else float(config["day_frac_cap"]) * float(day_ntl_usd)
|
|
4061
|
+
)
|
|
4062
|
+
return float(max(0.0, min(cap_depth, cap_turnover)))
|
|
4063
|
+
|
|
4064
|
+
@staticmethod
|
|
4065
|
+
def _order_scan_points(upper: float, *, growth: float = 1.8) -> list[float]:
|
|
4066
|
+
if upper <= 0:
|
|
4067
|
+
return []
|
|
4068
|
+
if upper <= 1.0:
|
|
4069
|
+
return [float(upper)]
|
|
4070
|
+
pts: list[float] = []
|
|
4071
|
+
v = 1.0
|
|
4072
|
+
while v < upper:
|
|
4073
|
+
pts.append(float(v))
|
|
4074
|
+
v *= float(growth)
|
|
4075
|
+
if len(pts) > 256:
|
|
4076
|
+
break
|
|
4077
|
+
pts.append(float(upper))
|
|
4078
|
+
# Dedupe + sort
|
|
4079
|
+
return sorted({float(p) for p in pts if p > 0.0})
|
|
4080
|
+
|
|
4081
|
+
async def max_spot_order_usd_for_book(
|
|
4082
|
+
self,
|
|
4083
|
+
*,
|
|
4084
|
+
spot_asset_id: int,
|
|
4085
|
+
spot_symbol: str | None,
|
|
4086
|
+
book: dict[str, Any],
|
|
4087
|
+
day_ntl_usd: float,
|
|
4088
|
+
params: dict[str, Any] | None = None,
|
|
4089
|
+
refine_iters: int = 12,
|
|
4090
|
+
) -> dict[str, Any]:
|
|
4091
|
+
"""
|
|
4092
|
+
Compute the maximum order_usd that passes spot depth checks on both sides.
|
|
4093
|
+
|
|
4094
|
+
This is used for batch precompute so workers can quickly filter candidates
|
|
4095
|
+
by a user's required order size.
|
|
4096
|
+
"""
|
|
4097
|
+
upper_buy = self._depth_upper_bound_usd(
|
|
4098
|
+
book=book, side="buy", day_ntl_usd=day_ntl_usd, params=params
|
|
4099
|
+
)
|
|
4100
|
+
upper_sell = self._depth_upper_bound_usd(
|
|
4101
|
+
book=book, side="sell", day_ntl_usd=day_ntl_usd, params=params
|
|
4102
|
+
)
|
|
4103
|
+
upper = min(upper_buy, upper_sell)
|
|
4104
|
+
if upper <= 0.0:
|
|
4105
|
+
return {
|
|
4106
|
+
"max_order_usd": 0.0,
|
|
4107
|
+
"upper_bound_usd": float(upper),
|
|
4108
|
+
"checks": {"buy": None, "sell": None},
|
|
4109
|
+
}
|
|
4110
|
+
|
|
4111
|
+
scan_orders = self._order_scan_points(upper)
|
|
4112
|
+
best = 0.0
|
|
4113
|
+
best_checks: dict[str, Any] | None = None
|
|
4114
|
+
|
|
4115
|
+
for order_usd in scan_orders:
|
|
4116
|
+
buy = await self.check_spot_depth_ok(
|
|
4117
|
+
spot_asset_id,
|
|
4118
|
+
float(order_usd),
|
|
4119
|
+
"buy",
|
|
4120
|
+
day_ntl_usd=day_ntl_usd,
|
|
4121
|
+
params=params,
|
|
4122
|
+
book=book,
|
|
4123
|
+
spot_symbol=spot_symbol,
|
|
4124
|
+
)
|
|
4125
|
+
sell = await self.check_spot_depth_ok(
|
|
4126
|
+
spot_asset_id,
|
|
4127
|
+
float(order_usd),
|
|
4128
|
+
"sell",
|
|
4129
|
+
day_ntl_usd=day_ntl_usd,
|
|
4130
|
+
params=params,
|
|
4131
|
+
book=book,
|
|
4132
|
+
spot_symbol=spot_symbol,
|
|
4133
|
+
)
|
|
4134
|
+
if bool(buy.get("pass")) and bool(sell.get("pass")):
|
|
4135
|
+
best = float(order_usd)
|
|
4136
|
+
best_checks = {"buy": buy, "sell": sell}
|
|
4137
|
+
|
|
4138
|
+
if best <= 0.0:
|
|
4139
|
+
# No scan point passed. Provide a diagnostic at the smallest order tested.
|
|
4140
|
+
first = float(scan_orders[0])
|
|
4141
|
+
buy = await self.check_spot_depth_ok(
|
|
4142
|
+
spot_asset_id,
|
|
4143
|
+
first,
|
|
4144
|
+
"buy",
|
|
4145
|
+
day_ntl_usd=day_ntl_usd,
|
|
4146
|
+
params=params,
|
|
4147
|
+
book=book,
|
|
4148
|
+
spot_symbol=spot_symbol,
|
|
4149
|
+
)
|
|
4150
|
+
sell = await self.check_spot_depth_ok(
|
|
4151
|
+
spot_asset_id,
|
|
4152
|
+
first,
|
|
4153
|
+
"sell",
|
|
4154
|
+
day_ntl_usd=day_ntl_usd,
|
|
4155
|
+
params=params,
|
|
4156
|
+
book=book,
|
|
4157
|
+
spot_symbol=spot_symbol,
|
|
4158
|
+
)
|
|
4159
|
+
return {
|
|
4160
|
+
"max_order_usd": 0.0,
|
|
4161
|
+
"upper_bound_usd": float(upper),
|
|
4162
|
+
"checks": {"buy": buy, "sell": sell},
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
# If the upper bound itself passes, we're done.
|
|
4166
|
+
if best >= float(upper) - 1e-9:
|
|
4167
|
+
return {
|
|
4168
|
+
"max_order_usd": float(upper),
|
|
4169
|
+
"upper_bound_usd": float(upper),
|
|
4170
|
+
"checks": best_checks or {"buy": None, "sell": None},
|
|
4171
|
+
}
|
|
4172
|
+
|
|
4173
|
+
# Find a failing point above best to bracket the threshold.
|
|
4174
|
+
bracket_high = float(upper)
|
|
4175
|
+
for order_usd in scan_orders:
|
|
4176
|
+
if float(order_usd) <= best:
|
|
4177
|
+
continue
|
|
4178
|
+
buy = await self.check_spot_depth_ok(
|
|
4179
|
+
spot_asset_id,
|
|
4180
|
+
float(order_usd),
|
|
4181
|
+
"buy",
|
|
4182
|
+
day_ntl_usd=day_ntl_usd,
|
|
4183
|
+
params=params,
|
|
4184
|
+
book=book,
|
|
4185
|
+
spot_symbol=spot_symbol,
|
|
4186
|
+
)
|
|
4187
|
+
sell = await self.check_spot_depth_ok(
|
|
4188
|
+
spot_asset_id,
|
|
4189
|
+
float(order_usd),
|
|
4190
|
+
"sell",
|
|
4191
|
+
day_ntl_usd=day_ntl_usd,
|
|
4192
|
+
params=params,
|
|
4193
|
+
book=book,
|
|
4194
|
+
spot_symbol=spot_symbol,
|
|
4195
|
+
)
|
|
4196
|
+
if not (bool(buy.get("pass")) and bool(sell.get("pass"))):
|
|
4197
|
+
bracket_high = float(order_usd)
|
|
4198
|
+
break
|
|
4199
|
+
|
|
4200
|
+
low = float(best)
|
|
4201
|
+
high = float(bracket_high)
|
|
4202
|
+
for _ in range(max(0, int(refine_iters))):
|
|
4203
|
+
if high - low <= 1e-6:
|
|
4204
|
+
break
|
|
4205
|
+
mid = (low + high) / 2.0
|
|
4206
|
+
buy = await self.check_spot_depth_ok(
|
|
4207
|
+
spot_asset_id,
|
|
4208
|
+
float(mid),
|
|
4209
|
+
"buy",
|
|
4210
|
+
day_ntl_usd=day_ntl_usd,
|
|
4211
|
+
params=params,
|
|
4212
|
+
book=book,
|
|
4213
|
+
spot_symbol=spot_symbol,
|
|
4214
|
+
)
|
|
4215
|
+
sell = await self.check_spot_depth_ok(
|
|
4216
|
+
spot_asset_id,
|
|
4217
|
+
float(mid),
|
|
4218
|
+
"sell",
|
|
4219
|
+
day_ntl_usd=day_ntl_usd,
|
|
4220
|
+
params=params,
|
|
4221
|
+
book=book,
|
|
4222
|
+
spot_symbol=spot_symbol,
|
|
4223
|
+
)
|
|
4224
|
+
if bool(buy.get("pass")) and bool(sell.get("pass")):
|
|
4225
|
+
low = float(mid)
|
|
4226
|
+
best_checks = {"buy": buy, "sell": sell}
|
|
4227
|
+
else:
|
|
4228
|
+
high = float(mid)
|
|
4229
|
+
|
|
4230
|
+
return {
|
|
4231
|
+
"max_order_usd": float(low),
|
|
4232
|
+
"upper_bound_usd": float(upper),
|
|
4233
|
+
"checks": best_checks or {"buy": None, "sell": None},
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
async def solve_candidates_max_net_apy_with_stop(
|
|
4237
|
+
self,
|
|
4238
|
+
*,
|
|
4239
|
+
deposit_usdc: float,
|
|
4240
|
+
stop_frac: float = 0.75,
|
|
4241
|
+
lookback_days: int = 45,
|
|
4242
|
+
oi_floor: float = 50.0,
|
|
4243
|
+
day_vlm_floor: float = 1e5,
|
|
4244
|
+
max_leverage: int = 3,
|
|
4245
|
+
fee_eps: float = 0.003,
|
|
4246
|
+
fee_model: dict[str, float] | None = None,
|
|
4247
|
+
depth_params: dict[str, Any] | None = None,
|
|
4248
|
+
perp_slippage_bps: float = 1.0,
|
|
4249
|
+
cooloff_hours: int = 0,
|
|
4250
|
+
coin_whitelist: list[str] | None = None,
|
|
4251
|
+
bootstrap_sims: int = DEFAULT_BOOTSTRAP_SIMS,
|
|
4252
|
+
bootstrap_block_hours: int = DEFAULT_BOOTSTRAP_BLOCK_HOURS,
|
|
4253
|
+
bootstrap_seed: int | None = None,
|
|
4254
|
+
) -> list[dict[str, Any]]:
|
|
4255
|
+
"""Rank spot/perp pairs by simulated net APY under stop-driven churn."""
|
|
4256
|
+
|
|
4257
|
+
if deposit_usdc <= 0:
|
|
4258
|
+
return []
|
|
4259
|
+
|
|
4260
|
+
max_hours = 5000
|
|
4261
|
+
lookback_days = min(int(lookback_days), max_hours // 24)
|
|
4262
|
+
|
|
4263
|
+
(
|
|
4264
|
+
success,
|
|
4265
|
+
perps_ctx_pack,
|
|
4266
|
+
) = await self.hyperliquid_adapter.get_meta_and_asset_ctxs()
|
|
4267
|
+
if not success:
|
|
4268
|
+
raise ValueError(f"Failed to fetch perp metadata: {perps_ctx_pack}")
|
|
4269
|
+
|
|
4270
|
+
perps_meta_list = perps_ctx_pack[0]["universe"]
|
|
4271
|
+
perps_ctxs = perps_ctx_pack[1]
|
|
4272
|
+
|
|
4273
|
+
coin_to_ctx: dict[str, Any] = {}
|
|
4274
|
+
coin_to_maxlev: dict[str, int] = {}
|
|
4275
|
+
coin_to_margin_table: dict[str, int | None] = {}
|
|
4276
|
+
coins: list[str] = []
|
|
4277
|
+
for meta, ctx in zip(perps_meta_list, perps_ctxs, strict=False):
|
|
4278
|
+
coin = meta["name"]
|
|
4279
|
+
coin_to_ctx[coin] = ctx
|
|
4280
|
+
coin_to_maxlev[coin] = int(meta.get("maxLeverage", 10))
|
|
4281
|
+
coin_to_margin_table[coin] = meta.get("marginTableId")
|
|
4282
|
+
coins.append(coin)
|
|
4283
|
+
perps_set = set(coins)
|
|
4284
|
+
|
|
4285
|
+
perp_coin_to_asset_id = {
|
|
4286
|
+
k: v for k, v in self.hyperliquid_adapter.coin_to_asset.items() if v < 10000
|
|
4287
|
+
}
|
|
4288
|
+
|
|
4289
|
+
success, spot_meta = await self.hyperliquid_adapter.get_spot_meta()
|
|
4290
|
+
if not success:
|
|
4291
|
+
raise ValueError(f"Failed to fetch spot metadata: {spot_meta}")
|
|
4292
|
+
|
|
4293
|
+
tokens = spot_meta.get("tokens", [])
|
|
4294
|
+
spot_pairs = spot_meta.get("universe", [])
|
|
4295
|
+
idx_to_token = {t["index"]: t["name"] for t in tokens}
|
|
4296
|
+
|
|
4297
|
+
candidates = self._find_basis_candidates(spot_pairs, idx_to_token, perps_set)
|
|
4298
|
+
|
|
4299
|
+
liquid_candidates = await self._filter_by_liquidity(
|
|
4300
|
+
candidates=candidates,
|
|
4301
|
+
coin_to_ctx=coin_to_ctx,
|
|
4302
|
+
coin_to_maxlev=coin_to_maxlev,
|
|
4303
|
+
coin_to_margin_table=coin_to_margin_table,
|
|
4304
|
+
deposit_usdc=deposit_usdc,
|
|
4305
|
+
max_leverage=max_leverage,
|
|
4306
|
+
oi_floor=oi_floor,
|
|
4307
|
+
day_vlm_floor=day_vlm_floor,
|
|
4308
|
+
perp_coin_to_asset_id=perp_coin_to_asset_id,
|
|
4309
|
+
depth_params=depth_params,
|
|
4310
|
+
)
|
|
4311
|
+
|
|
4312
|
+
whitelist = (
|
|
4313
|
+
{coin.upper() for coin in coin_whitelist} if coin_whitelist else None
|
|
4314
|
+
)
|
|
4315
|
+
if whitelist is not None:
|
|
4316
|
+
liquid_candidates = [
|
|
4317
|
+
candidate
|
|
4318
|
+
for candidate in liquid_candidates
|
|
4319
|
+
if candidate.coin.upper() in whitelist
|
|
4320
|
+
]
|
|
4321
|
+
if not liquid_candidates:
|
|
4322
|
+
return []
|
|
4323
|
+
|
|
4324
|
+
if not liquid_candidates:
|
|
4325
|
+
return []
|
|
4326
|
+
|
|
4327
|
+
ms_now = int(time.time() * 1000)
|
|
4328
|
+
start_ms = ms_now - int(lookback_days * 24 * 3600 * 1000)
|
|
4329
|
+
|
|
4330
|
+
ranked: list[dict[str, Any]] = []
|
|
4331
|
+
|
|
4332
|
+
for candidate in liquid_candidates:
|
|
4333
|
+
coin = candidate.coin
|
|
4334
|
+
spot_sym = candidate.spot_pair
|
|
4335
|
+
spot_asset_id = candidate.spot_asset_id
|
|
4336
|
+
perp_asset_id = candidate.perp_asset_id
|
|
4337
|
+
spot_book = candidate.spot_book
|
|
4338
|
+
mark_px = float(candidate.mark_price)
|
|
4339
|
+
max_available_lev = max(1, int(candidate.target_leverage))
|
|
4340
|
+
margin_table_id = candidate.margin_table_id
|
|
4341
|
+
|
|
4342
|
+
if margin_table_id:
|
|
4343
|
+
await self._get_margin_table_tiers(int(margin_table_id))
|
|
4344
|
+
|
|
4345
|
+
(
|
|
4346
|
+
(funding_ok, funding_data),
|
|
4347
|
+
(candles_ok, candle_data),
|
|
4348
|
+
) = await asyncio.gather(
|
|
4349
|
+
self._fetch_funding_history_chunked(coin, start_ms, ms_now),
|
|
4350
|
+
self._fetch_candles_chunked(coin, "1h", start_ms, ms_now),
|
|
4351
|
+
)
|
|
4352
|
+
if not funding_ok or not candles_ok:
|
|
4353
|
+
continue
|
|
4354
|
+
|
|
4355
|
+
hourly_funding = [float(x.get("fundingRate", 0.0)) for x in funding_data]
|
|
4356
|
+
closes = [float(c.get("c", 0)) for c in candle_data if c.get("c")]
|
|
4357
|
+
highs = [float(c.get("h", 0)) for c in candle_data if c.get("h")]
|
|
4358
|
+
|
|
4359
|
+
n_ok = min(len(hourly_funding), len(closes), len(highs))
|
|
4360
|
+
if n_ok < (lookback_days * 24 - 48):
|
|
4361
|
+
continue
|
|
4362
|
+
|
|
4363
|
+
best_choice: dict[str, Any] | None = None
|
|
4364
|
+
|
|
4365
|
+
for L in range(1, max_available_lev + 1):
|
|
4366
|
+
N_leg_usd = deposit_usdc * (float(L) / (float(L) + 1.0))
|
|
4367
|
+
entry_mmr = self.maintenance_fraction_for_notional(
|
|
4368
|
+
margin_table_id,
|
|
4369
|
+
N_leg_usd,
|
|
4370
|
+
max_available_lev,
|
|
4371
|
+
)
|
|
4372
|
+
|
|
4373
|
+
(
|
|
4374
|
+
entry_cost,
|
|
4375
|
+
exit_cost,
|
|
4376
|
+
cost_breakdown,
|
|
4377
|
+
depth_checks,
|
|
4378
|
+
) = await self._estimate_cycle_costs(
|
|
4379
|
+
N_leg_usd=N_leg_usd,
|
|
4380
|
+
spot_asset_id=spot_asset_id,
|
|
4381
|
+
spot_book=spot_book,
|
|
4382
|
+
fee_model=fee_model,
|
|
4383
|
+
depth_params=depth_params,
|
|
4384
|
+
perp_slippage_bps=perp_slippage_bps,
|
|
4385
|
+
day_ntl_usd=candidate.day_notional_usd,
|
|
4386
|
+
spot_symbol=spot_sym,
|
|
4387
|
+
)
|
|
4388
|
+
|
|
4389
|
+
sim = self._simulate_barrier_backtest(
|
|
4390
|
+
funding=hourly_funding,
|
|
4391
|
+
closes=closes,
|
|
4392
|
+
highs=highs,
|
|
4393
|
+
leverage=L,
|
|
4394
|
+
stop_frac=stop_frac,
|
|
4395
|
+
fee_eps=fee_eps,
|
|
4396
|
+
N_leg_usd=N_leg_usd,
|
|
4397
|
+
entry_cost_usd=entry_cost,
|
|
4398
|
+
exit_cost_usd=exit_cost,
|
|
4399
|
+
margin_table_id=margin_table_id,
|
|
4400
|
+
fallback_max_leverage=max_available_lev,
|
|
4401
|
+
cooloff_hours=cooloff_hours,
|
|
4402
|
+
)
|
|
4403
|
+
|
|
4404
|
+
hours = max(1.0, float(sim["hours"]))
|
|
4405
|
+
years = hours / (24.0 * 365.0)
|
|
4406
|
+
net_apy = (float(sim["net_pnl_usd"]) / max(1e-9, deposit_usdc)) / years
|
|
4407
|
+
gross_apy = (
|
|
4408
|
+
float(sim["gross_funding_usd"]) / max(1e-9, deposit_usdc)
|
|
4409
|
+
) / years
|
|
4410
|
+
hit_rate_per_day = (
|
|
4411
|
+
float(sim["cycles"]) / (hours / 24.0) if hours > 0 else 0.0
|
|
4412
|
+
)
|
|
4413
|
+
avg_hold_hours = (
|
|
4414
|
+
float(sim["hours_in_market"]) / max(1.0, float(sim["cycles"]))
|
|
4415
|
+
if float(sim["cycles"]) > 0
|
|
4416
|
+
else hours
|
|
4417
|
+
)
|
|
4418
|
+
time_in_market = float(sim["hours_in_market"]) / hours
|
|
4419
|
+
|
|
4420
|
+
bootstrap_stats = self._bootstrap_churn_metrics(
|
|
4421
|
+
funding=hourly_funding,
|
|
4422
|
+
closes=closes,
|
|
4423
|
+
highs=highs,
|
|
4424
|
+
leverage=L,
|
|
4425
|
+
stop_frac=stop_frac,
|
|
4426
|
+
fee_eps=fee_eps,
|
|
4427
|
+
N_leg_usd=N_leg_usd,
|
|
4428
|
+
entry_cost_usd=entry_cost,
|
|
4429
|
+
exit_cost_usd=exit_cost,
|
|
4430
|
+
margin_table_id=margin_table_id,
|
|
4431
|
+
fallback_max_leverage=max_available_lev,
|
|
4432
|
+
cooloff_hours=cooloff_hours,
|
|
4433
|
+
deposit_usdc=deposit_usdc,
|
|
4434
|
+
sims=bootstrap_sims,
|
|
4435
|
+
block_hours=bootstrap_block_hours,
|
|
4436
|
+
seed=None
|
|
4437
|
+
if bootstrap_seed is None
|
|
4438
|
+
else hash((bootstrap_seed, coin, L)),
|
|
4439
|
+
)
|
|
4440
|
+
|
|
4441
|
+
choice: dict[str, Any] = {
|
|
4442
|
+
"coin": coin,
|
|
4443
|
+
"spot_pair": spot_sym,
|
|
4444
|
+
"spot_asset_id": spot_asset_id,
|
|
4445
|
+
"best_L": int(L),
|
|
4446
|
+
"net_apy": float(net_apy),
|
|
4447
|
+
"gross_funding_apy": float(gross_apy),
|
|
4448
|
+
"entry_cost_usd": float(entry_cost),
|
|
4449
|
+
"exit_cost_usd": float(exit_cost),
|
|
4450
|
+
"cycles": float(sim["cycles"]),
|
|
4451
|
+
"hit_rate_per_day": float(hit_rate_per_day),
|
|
4452
|
+
"avg_hold_hours": float(avg_hold_hours),
|
|
4453
|
+
"time_in_market_frac": float(time_in_market),
|
|
4454
|
+
"stop_frac": float(stop_frac),
|
|
4455
|
+
"cost_breakdown": cost_breakdown,
|
|
4456
|
+
"depth_checks": depth_checks,
|
|
4457
|
+
"mark_price": float(mark_px),
|
|
4458
|
+
"perp_asset_id": int(perp_asset_id),
|
|
4459
|
+
"mmr": float(entry_mmr),
|
|
4460
|
+
"margin_table_id": margin_table_id,
|
|
4461
|
+
"max_coin_leverage": int(max_available_lev),
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4464
|
+
if bootstrap_stats is not None:
|
|
4465
|
+
choice["bootstrap_metrics"] = bootstrap_stats
|
|
4466
|
+
|
|
4467
|
+
if best_choice is None or choice["net_apy"] > best_choice["net_apy"]:
|
|
4468
|
+
best_choice = choice
|
|
4469
|
+
|
|
4470
|
+
if best_choice and best_choice["net_apy"] > float("-inf"):
|
|
4471
|
+
ranked.append(best_choice)
|
|
4472
|
+
|
|
4473
|
+
ranked.sort(key=lambda x: float(x.get("net_apy", float("-inf"))), reverse=True)
|
|
4474
|
+
return ranked
|
|
4475
|
+
|
|
4476
|
+
# ------------------------------------------------------------------ #
|
|
4477
|
+
# Utility Methods #
|
|
4478
|
+
# ------------------------------------------------------------------ #
|
|
4479
|
+
|
|
4480
|
+
def _z_from_conf(self, confidence: float) -> float:
|
|
4481
|
+
"""Get z-score for given confidence level."""
|
|
4482
|
+
return analytics_z_from_conf(confidence)
|
|
4483
|
+
|
|
4484
|
+
def _rolling_min_sum(self, arr: list[float], window: int) -> float:
|
|
4485
|
+
"""Calculate minimum rolling sum over window."""
|
|
4486
|
+
return analytics_rolling_min_sum(arr, window)
|
|
4487
|
+
|
|
4488
|
+
@staticmethod
|
|
4489
|
+
def maintenance_rate_from_max_leverage(max_lev: int) -> float:
|
|
4490
|
+
"""Estimate maintenance margin rate from max leverage."""
|
|
4491
|
+
if max_lev <= 0:
|
|
4492
|
+
return 0.5
|
|
4493
|
+
return 0.5 / max_lev
|
|
4494
|
+
|
|
4495
|
+
@staticmethod
|
|
4496
|
+
def _get_safe_apy_key(result: dict[str, Any]) -> float:
|
|
4497
|
+
"""Sort key for results by 7d expected APY."""
|
|
4498
|
+
safe = result.get("safe", {})
|
|
4499
|
+
safe_7d = safe.get("7d", {})
|
|
4500
|
+
if not safe_7d.get("pass", False):
|
|
4501
|
+
return -999.0
|
|
4502
|
+
return safe_7d.get("expected_apy_pct", 0.0)
|
|
4503
|
+
|
|
4504
|
+
def _get_strategy_wallet_address(self) -> str:
|
|
4505
|
+
"""Get strategy wallet address from config."""
|
|
4506
|
+
strategy_wallet = self.config.get("strategy_wallet")
|
|
4507
|
+
if not strategy_wallet or not isinstance(strategy_wallet, dict):
|
|
4508
|
+
raise ValueError("strategy_wallet not configured")
|
|
4509
|
+
address = strategy_wallet.get("address")
|
|
4510
|
+
if not address:
|
|
4511
|
+
raise ValueError("strategy_wallet address not found")
|
|
4512
|
+
return str(address)
|
|
4513
|
+
|
|
4514
|
+
def _get_main_wallet_address(self) -> str:
|
|
4515
|
+
"""Get main wallet address from config."""
|
|
4516
|
+
main_wallet = self.config.get("main_wallet")
|
|
4517
|
+
if not main_wallet or not isinstance(main_wallet, dict):
|
|
4518
|
+
raise ValueError("main_wallet not configured")
|
|
4519
|
+
address = main_wallet.get("address")
|
|
4520
|
+
if not address:
|
|
4521
|
+
raise ValueError("main_wallet address not found")
|
|
4522
|
+
return str(address)
|