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,2270 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import math
|
|
3
|
+
import time
|
|
4
|
+
import unicodedata
|
|
5
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
6
|
+
from decimal import ROUND_DOWN, ROUND_UP, Decimal
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from loguru import logger
|
|
12
|
+
from web3 import Web3
|
|
13
|
+
|
|
14
|
+
from wayfinder_paths.adapters.balance_adapter.adapter import BalanceAdapter
|
|
15
|
+
from wayfinder_paths.adapters.brap_adapter.adapter import BRAPAdapter
|
|
16
|
+
from wayfinder_paths.adapters.hyperlend_adapter.adapter import HyperlendAdapter
|
|
17
|
+
from wayfinder_paths.adapters.ledger_adapter.adapter import LedgerAdapter
|
|
18
|
+
from wayfinder_paths.adapters.token_adapter.adapter import TokenAdapter
|
|
19
|
+
from wayfinder_paths.core.constants.base import DEFAULT_SLIPPAGE
|
|
20
|
+
from wayfinder_paths.core.services.base import Web3Service
|
|
21
|
+
from wayfinder_paths.core.services.local_token_txn import (
|
|
22
|
+
LocalTokenTxnService,
|
|
23
|
+
)
|
|
24
|
+
from wayfinder_paths.core.services.web3_service import DefaultWeb3Service
|
|
25
|
+
from wayfinder_paths.core.strategies.descriptors import (
|
|
26
|
+
Complexity,
|
|
27
|
+
Directionality,
|
|
28
|
+
Frequency,
|
|
29
|
+
StratDescriptor,
|
|
30
|
+
TokenExposure,
|
|
31
|
+
Volatility,
|
|
32
|
+
)
|
|
33
|
+
from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
|
|
34
|
+
from wayfinder_paths.core.wallets.WalletManager import WalletManager
|
|
35
|
+
from wayfinder_paths.policies.enso import ENSO_ROUTER, enso_swap
|
|
36
|
+
from wayfinder_paths.policies.erc20 import erc20_spender_for_any_token
|
|
37
|
+
from wayfinder_paths.policies.hyper_evm import (
|
|
38
|
+
hypecore_sentinel_deposit,
|
|
39
|
+
whype_deposit_and_withdraw,
|
|
40
|
+
)
|
|
41
|
+
from wayfinder_paths.policies.hyperlend import (
|
|
42
|
+
HYPERLEND_POOL,
|
|
43
|
+
hyperlend_supply_and_withdraw,
|
|
44
|
+
)
|
|
45
|
+
from wayfinder_paths.policies.hyperliquid import (
|
|
46
|
+
any_hyperliquid_l1_payload,
|
|
47
|
+
any_hyperliquid_user_payload,
|
|
48
|
+
)
|
|
49
|
+
from wayfinder_paths.policies.prjx import PRJX_ROUTER, prjx_swap
|
|
50
|
+
|
|
51
|
+
SYMBOL_TRANSLATION_TABLE = str.maketrans(
|
|
52
|
+
{
|
|
53
|
+
"₮": "T",
|
|
54
|
+
"₿": "B",
|
|
55
|
+
"Ξ": "X",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
WRAPPED_HYPE_ADDRESS = "0x5555555555555555555555555555555555555555"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HyperlendStableYieldStrategy(Strategy):
|
|
62
|
+
name = "HyperLend Stable Optimizer"
|
|
63
|
+
|
|
64
|
+
# Strategy parameters
|
|
65
|
+
APY_SHORT_CIRCUIT_THRESHOLD = None
|
|
66
|
+
MIN_USDT0_DEPOSIT_AMOUNT = 1
|
|
67
|
+
HORIZON_HOURS = 6
|
|
68
|
+
BLOCK_LEN = 6
|
|
69
|
+
TRIALS = 4000
|
|
70
|
+
HALFLIFE_DAYS = 7
|
|
71
|
+
SEED = 7
|
|
72
|
+
HYSTERESIS_DWELL_HOURS = 168
|
|
73
|
+
HYSTERESIS_Z = 1.15
|
|
74
|
+
GAS_MAXIMUM = 0.1
|
|
75
|
+
ROTATION_POLICY = "hysteresis"
|
|
76
|
+
ROTATION_TX_COST = 0.002
|
|
77
|
+
SUPPLY_CAP_BUFFER_BPS = 50
|
|
78
|
+
SUPPLY_CAP_MIN_BUFFER_TOKENS = 0.5
|
|
79
|
+
ASSETS_SNAPSHOT_TTL_SECONDS = 20.0
|
|
80
|
+
DEFAULT_LOOKBACK_HOURS = 24 * 7
|
|
81
|
+
APY_REBALANCE_THRESHOLD = 0.0035
|
|
82
|
+
TOURNAMENT_MODE = "joint" # "joint" or "independent"
|
|
83
|
+
ROTATION_COOLDOWN = timedelta(hours=168)
|
|
84
|
+
P_BEST_ROTATION_THRESHOLD = 0.4
|
|
85
|
+
MAX_CANDIDATES = 5
|
|
86
|
+
MIN_STABLE_SWAP_TOKENS = 1e-3
|
|
87
|
+
MAX_GAS = 0.1 # hype float
|
|
88
|
+
|
|
89
|
+
INFO = StratDescriptor(
|
|
90
|
+
description=f"""Multi-strategy allocator that converts USDT0 into the most consistently rewarding HyperLend stablecoin and continuously checks if a rotation is justified.
|
|
91
|
+
**What it does:** Pulls USDT0 from the main wallet, ensures a small HYPE safety buffer for gas, swaps the remaining stable balance into candidate markets, and supplies
|
|
92
|
+
liquidity to HyperLend. Hourly rate histories are aggregated into a 7-day panel and routed through a block-bootstrap tournament (horizon {HORIZON_HOURS}h, block length {BLOCK_LEN}, {TRIALS:,}
|
|
93
|
+
trials, {HALFLIFE_DAYS}-day half-life weighting) to estimate which asset has the highest probability of outperforming peers. USDT0 is the LayerZero bridgable stablecoin for USDT.
|
|
94
|
+
**Exposure type:** Market-neutral stablecoin lending on HyperEVM with automated rotation into whichever pool offers the strongest risk-adjusted lending yield.
|
|
95
|
+
**Chains:** HyperEVM only (HyperLend pool suite).
|
|
96
|
+
**Deposit/Withdrawal:** Deposits move USDT0 from the main wallet into the strategy wallet, top up a minimal HYPE gas buffer, rotate into the selected stable, and lend it via HyperLend.
|
|
97
|
+
Withdrawals unwind the lend position, convert balances back to USDT, and return funds (plus residual HYPE) to the main wallet.
|
|
98
|
+
**Gas**: Requires HYPE on HypeEVM. Arbitrary amount of funding gas is accepted via strategy wallet transfers.
|
|
99
|
+
""",
|
|
100
|
+
summary=(
|
|
101
|
+
"Recency-weighted HyperLend stablecoin optimizer that bootstraps 7-day rate history "
|
|
102
|
+
f"(horizon {HORIZON_HOURS}h, {BLOCK_LEN}-hour blocks, {TRIALS:,} simulations) to pick the top "
|
|
103
|
+
"performer, funds with USDT0, tops up a small HYPE gas buffer, and defaults to a hysteresis "
|
|
104
|
+
f"rotation band (dwell={HYSTERESIS_DWELL_HOURS}h, z={HYSTERESIS_Z:.2f}) to avoid churn while still "
|
|
105
|
+
"short-circuiting when yield gaps are extreme."
|
|
106
|
+
),
|
|
107
|
+
gas_token_symbol="HYPE",
|
|
108
|
+
gas_token_id="hyperliquid-hyperevm",
|
|
109
|
+
deposit_token_id="usdt0-hyperevm",
|
|
110
|
+
minimum_net_deposit=10,
|
|
111
|
+
gas_maximum=MAX_GAS, # hype float
|
|
112
|
+
gas_threshold=MAX_GAS / 3,
|
|
113
|
+
# risk indicators
|
|
114
|
+
volatility=Volatility.LOW,
|
|
115
|
+
volatility_description_short=(
|
|
116
|
+
"Pure HyperLend stablecoin lending keeps NAV steady aside from rate drift."
|
|
117
|
+
),
|
|
118
|
+
directionality=Directionality.MARKET_NEUTRAL,
|
|
119
|
+
directionality_description=(
|
|
120
|
+
"Rotates capital between USD stables so exposure stays market neutral."
|
|
121
|
+
),
|
|
122
|
+
complexity=Complexity.LOW,
|
|
123
|
+
complexity_description="Agent handles optimal pool finding, swaps, and lend transactions automatically.",
|
|
124
|
+
token_exposure=TokenExposure.STABLECOINS,
|
|
125
|
+
token_exposure_description=(
|
|
126
|
+
"Only HyperEVM USD stables (USDT0 and peers), no volatile tokens."
|
|
127
|
+
),
|
|
128
|
+
frequency=Frequency.LOW,
|
|
129
|
+
frequency_description=(
|
|
130
|
+
"Updates every 2 hours; rotations infrequent (weekly cooldowns)."
|
|
131
|
+
),
|
|
132
|
+
return_drivers=["lend APY", "pool yield"],
|
|
133
|
+
config={
|
|
134
|
+
"deposit": {
|
|
135
|
+
"description": "Move USDT0 into the strategy, ensure a small HYPE gas buffer, and supply the best HyperLend stable.",
|
|
136
|
+
"parameters": {
|
|
137
|
+
"main_token_amount": {
|
|
138
|
+
"type": "float",
|
|
139
|
+
"unit": "USDT0 tokens",
|
|
140
|
+
"description": "Amount of USDT0 to allocate to HyperLend.",
|
|
141
|
+
"minimum": 1.0, # TODO: 10
|
|
142
|
+
"examples": ["100.0", "250.5"],
|
|
143
|
+
},
|
|
144
|
+
"gas_token_amount": {
|
|
145
|
+
"type": "float",
|
|
146
|
+
"unit": "HYPE tokens",
|
|
147
|
+
"description": "Amount of HYPE to top up into the strategy wallet to cover gas costs.",
|
|
148
|
+
"minimum": 0.0,
|
|
149
|
+
"maximum": GAS_MAXIMUM,
|
|
150
|
+
"recommended": GAS_MAXIMUM,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
"result": "USDT0 converted into the top-performing HyperLend stablecoin and supplied on-chain.",
|
|
154
|
+
},
|
|
155
|
+
"withdraw": {
|
|
156
|
+
"description": "Unwinds the position, converts balances to USDT0, and returns funds (plus HYPE buffer) to the main wallet.",
|
|
157
|
+
"parameters": {},
|
|
158
|
+
"result": "Principal and accrued gains returned in USDT0; residual HYPE buffer swept home.",
|
|
159
|
+
},
|
|
160
|
+
"update": {
|
|
161
|
+
"description": (
|
|
162
|
+
"Evaluates tournament projections and rotates when the hysteresis band is breached "
|
|
163
|
+
f"(dwell={HYSTERESIS_DWELL_HOURS}h, z={HYSTERESIS_Z:.2f}) or when a short-circuit gap is hit "
|
|
164
|
+
"(set HYPERLEND_ROTATION_POLICY=cooldown to restore the legacy threshold/cooldown rule)."
|
|
165
|
+
),
|
|
166
|
+
"parameters": {},
|
|
167
|
+
},
|
|
168
|
+
"status": {
|
|
169
|
+
"description": "Summarises current lend position, APY, and chosen asset.",
|
|
170
|
+
"provides": [
|
|
171
|
+
"lent_asset",
|
|
172
|
+
"lent_balance",
|
|
173
|
+
"current_apy",
|
|
174
|
+
"best_candidate",
|
|
175
|
+
"best_candidate_apy",
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
"points": {
|
|
179
|
+
"description": "Fetch the HyperLend points account snapshot for this strategy wallet using a signed login.",
|
|
180
|
+
"parameters": {},
|
|
181
|
+
"result": "Returns the HyperLend points API payload for the strategy wallet.",
|
|
182
|
+
},
|
|
183
|
+
"technical_details": {
|
|
184
|
+
"rotation_policy": ROTATION_POLICY.lower(),
|
|
185
|
+
"hysteresis_dwell_hours": HYSTERESIS_DWELL_HOURS,
|
|
186
|
+
"hysteresis_z": HYSTERESIS_Z,
|
|
187
|
+
"rotation_tx_cost": ROTATION_TX_COST,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
config: dict[str, Any] | None = None,
|
|
195
|
+
*,
|
|
196
|
+
main_wallet: dict[str, Any] | None = None,
|
|
197
|
+
strategy_wallet: dict[str, Any] | None = None,
|
|
198
|
+
simulation: bool = False,
|
|
199
|
+
web3_service: Web3Service = None,
|
|
200
|
+
api_key: str | None = None,
|
|
201
|
+
):
|
|
202
|
+
super().__init__(api_key=api_key)
|
|
203
|
+
merged_config: dict[str, Any] = dict(config or {})
|
|
204
|
+
if main_wallet is not None:
|
|
205
|
+
merged_config["main_wallet"] = main_wallet
|
|
206
|
+
if strategy_wallet is not None:
|
|
207
|
+
merged_config["strategy_wallet"] = strategy_wallet
|
|
208
|
+
|
|
209
|
+
self.config = merged_config
|
|
210
|
+
self.simulation = simulation
|
|
211
|
+
self.balance_adapter = None
|
|
212
|
+
self.tx_adapter = None
|
|
213
|
+
self.token_adapter = None
|
|
214
|
+
self.evm_transaction_adapter = None
|
|
215
|
+
self.web3_service = web3_service
|
|
216
|
+
self.pool_adapter = None
|
|
217
|
+
self.brap_adapter = None
|
|
218
|
+
self.hyperlend_adapter = None
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
main_wallet_cfg = self.config.get("main_wallet")
|
|
222
|
+
strategy_wallet_cfg = self.config.get("strategy_wallet")
|
|
223
|
+
|
|
224
|
+
# Validate wallets are configured
|
|
225
|
+
if not strategy_wallet_cfg or not strategy_wallet_cfg.get("address"):
|
|
226
|
+
raise ValueError(
|
|
227
|
+
"strategy_wallet not configured. Provide strategy_wallet address in config or ensure wallet is properly configured for your wallet provider"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
adapter_config = {
|
|
231
|
+
"main_wallet": main_wallet_cfg or None,
|
|
232
|
+
"strategy_wallet": strategy_wallet_cfg or None,
|
|
233
|
+
"strategy": self.config,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if self.web3_service is None:
|
|
237
|
+
wallet_provider = WalletManager.get_provider(adapter_config)
|
|
238
|
+
token_transaction_service = LocalTokenTxnService(
|
|
239
|
+
adapter_config,
|
|
240
|
+
wallet_provider=wallet_provider,
|
|
241
|
+
simulation=self.simulation,
|
|
242
|
+
)
|
|
243
|
+
web3_service = DefaultWeb3Service(
|
|
244
|
+
wallet_provider=wallet_provider,
|
|
245
|
+
evm_transactions=token_transaction_service,
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
web3_service = self.web3_service
|
|
249
|
+
token_transaction_service = web3_service.token_transactions
|
|
250
|
+
balance = BalanceAdapter(adapter_config, web3_service=web3_service)
|
|
251
|
+
token_adapter = TokenAdapter()
|
|
252
|
+
ledger_adapter = LedgerAdapter() # here
|
|
253
|
+
brap_adapter = BRAPAdapter(
|
|
254
|
+
web3_service=web3_service, simulation=self.simulation
|
|
255
|
+
)
|
|
256
|
+
hyperlend_adapter = HyperlendAdapter(
|
|
257
|
+
adapter_config,
|
|
258
|
+
simulation=self.simulation,
|
|
259
|
+
web3_service=web3_service,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
self.register_adapters(
|
|
263
|
+
[
|
|
264
|
+
balance,
|
|
265
|
+
token_adapter,
|
|
266
|
+
ledger_adapter,
|
|
267
|
+
brap_adapter,
|
|
268
|
+
hyperlend_adapter,
|
|
269
|
+
token_transaction_service,
|
|
270
|
+
]
|
|
271
|
+
)
|
|
272
|
+
self.balance_adapter = balance
|
|
273
|
+
self.evm_transaction_adapter = token_transaction_service
|
|
274
|
+
self.web3_service = web3_service
|
|
275
|
+
self.token_adapter = token_adapter
|
|
276
|
+
self.ledger_adapter = ledger_adapter
|
|
277
|
+
self.brap_adapter = brap_adapter
|
|
278
|
+
self.hyperlend_adapter = hyperlend_adapter
|
|
279
|
+
|
|
280
|
+
self._assets_snapshot = None
|
|
281
|
+
self._assets_snapshot_at = None
|
|
282
|
+
self._assets_snapshot_lock = asyncio.Lock()
|
|
283
|
+
self.symbol_display_map = {}
|
|
284
|
+
|
|
285
|
+
except Exception as e:
|
|
286
|
+
logger.error(f"Failed to initialize strategy adapters: {e}")
|
|
287
|
+
raise
|
|
288
|
+
|
|
289
|
+
async def setup(self):
|
|
290
|
+
if self.token_adapter is None:
|
|
291
|
+
raise RuntimeError(
|
|
292
|
+
"Token adapter not initialized. Strategy initialization may have failed."
|
|
293
|
+
)
|
|
294
|
+
try:
|
|
295
|
+
success, self.usdt_token_info = await self.token_adapter.get_token(
|
|
296
|
+
"usdt0-hyperevm"
|
|
297
|
+
)
|
|
298
|
+
if not success:
|
|
299
|
+
self.usdt_token_info = {}
|
|
300
|
+
|
|
301
|
+
success, self.hype_token_info = await self.token_adapter.get_token(
|
|
302
|
+
"hype-hyperevm"
|
|
303
|
+
)
|
|
304
|
+
if not success:
|
|
305
|
+
self.hype_token_info = {}
|
|
306
|
+
except Exception:
|
|
307
|
+
self.usdt_token_info = {}
|
|
308
|
+
self.hype_token_info = {}
|
|
309
|
+
|
|
310
|
+
self.current_token = None
|
|
311
|
+
self.current_symbol = None
|
|
312
|
+
self.current_avg_apy = 0.0
|
|
313
|
+
self.kept_hype_tokens = 0.0
|
|
314
|
+
|
|
315
|
+
self.last_summary: pd.DataFrame | None = None
|
|
316
|
+
self.last_dominance: pd.DataFrame | None = None
|
|
317
|
+
self.last_samples: np.ndarray | None = None
|
|
318
|
+
|
|
319
|
+
self.rotation_policy = self.ROTATION_POLICY
|
|
320
|
+
if self.rotation_policy not in {"hysteresis", "cooldown"}:
|
|
321
|
+
self.rotation_policy = "hysteresis"
|
|
322
|
+
self.hys_dwell_hours: int = max(1, self.HYSTERESIS_DWELL_HOURS)
|
|
323
|
+
self.hys_z: float = self.HYSTERESIS_Z
|
|
324
|
+
self.rotation_tx_cost: float = self.ROTATION_TX_COST
|
|
325
|
+
|
|
326
|
+
async def deposit(self) -> StatusTuple:
|
|
327
|
+
self._invalidate_assets_snapshot()
|
|
328
|
+
await self._hydrate_position_from_chain()
|
|
329
|
+
|
|
330
|
+
return (True, "hydrated positions")
|
|
331
|
+
|
|
332
|
+
async def _estimate_redeploy_tokens(self) -> float:
|
|
333
|
+
positions = await self._get_lent_positions()
|
|
334
|
+
total_tokens = 0.0
|
|
335
|
+
|
|
336
|
+
for entry in positions.values():
|
|
337
|
+
token = entry.get("token")
|
|
338
|
+
amount_wei = entry.get("amount_wei", 0)
|
|
339
|
+
if not token or amount_wei <= 0:
|
|
340
|
+
continue
|
|
341
|
+
try:
|
|
342
|
+
total_tokens += float(amount_wei) / 10 ** token.get("decimals")
|
|
343
|
+
except Exception:
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
return total_tokens
|
|
347
|
+
|
|
348
|
+
def _amount_to_wei(self, token: dict[str, Any], amount: Decimal) -> int:
|
|
349
|
+
"""Convert ``amount`` tokens into base units using existing helpers."""
|
|
350
|
+
|
|
351
|
+
if amount <= 0:
|
|
352
|
+
return 0
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
return int(amount * (10 ** token.get("decimals")))
|
|
356
|
+
except Exception:
|
|
357
|
+
try:
|
|
358
|
+
decimals = int(getattr(token, "decimals", 18))
|
|
359
|
+
except (TypeError, ValueError):
|
|
360
|
+
decimals = 18
|
|
361
|
+
scale = Decimal(10) ** decimals
|
|
362
|
+
return int((amount * scale).to_integral_value(rounding=ROUND_UP))
|
|
363
|
+
|
|
364
|
+
def _display_symbol(self, symbol: str | None) -> str:
|
|
365
|
+
if not symbol:
|
|
366
|
+
return ""
|
|
367
|
+
display = self.symbol_display_map.get(symbol)
|
|
368
|
+
if display:
|
|
369
|
+
return str(display)
|
|
370
|
+
return str(symbol).upper()
|
|
371
|
+
|
|
372
|
+
async def _hydrate_position_from_chain(self) -> None:
|
|
373
|
+
snapshot = await self._get_assets_snapshot()
|
|
374
|
+
asset_map = (
|
|
375
|
+
snapshot.get("_by_underlying", {}) if isinstance(snapshot, dict) else {}
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
if self.current_token:
|
|
379
|
+
checksum = self._token_checksum(self.current_token)
|
|
380
|
+
asset = asset_map.get(checksum) if checksum else None
|
|
381
|
+
supply = float(asset.get("supply", 0.0)) if asset else 0.0
|
|
382
|
+
if supply > 0.0:
|
|
383
|
+
symbol = self.current_token.get("symbol", None)
|
|
384
|
+
display = asset.get("symbol_display") if asset else symbol
|
|
385
|
+
if symbol and display:
|
|
386
|
+
self.symbol_display_map.setdefault(str(symbol), display)
|
|
387
|
+
self.current_avg_apy = float(asset.get("supply_apy") or 0.0)
|
|
388
|
+
return True
|
|
389
|
+
self.current_token = None
|
|
390
|
+
self.current_symbol = None
|
|
391
|
+
self.current_avg_apy = 0.0
|
|
392
|
+
|
|
393
|
+
positions = await self._get_lent_positions(snapshot)
|
|
394
|
+
if not positions:
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
top_entry = max(positions.values(), key=lambda entry: entry["amount_wei"])
|
|
398
|
+
if top_entry.get("amount_wei") <= 0:
|
|
399
|
+
return False
|
|
400
|
+
|
|
401
|
+
token = top_entry.get("token")
|
|
402
|
+
if not token.get("address"):
|
|
403
|
+
token["address"] = top_entry.get("asset").get("underlying")
|
|
404
|
+
self.current_token = token
|
|
405
|
+
symbol = token.get("symbol", None)
|
|
406
|
+
checksum = self._token_checksum(token)
|
|
407
|
+
asset = asset_map.get(checksum) if checksum else None
|
|
408
|
+
if not symbol and asset:
|
|
409
|
+
symbol = asset.get("symbol") or asset.get("symbol_display")
|
|
410
|
+
self.current_symbol = symbol
|
|
411
|
+
display_symbol = asset.get("symbol_display") if asset else None
|
|
412
|
+
if symbol:
|
|
413
|
+
self.symbol_display_map.setdefault(
|
|
414
|
+
str(symbol), display_symbol or symbol.upper()
|
|
415
|
+
)
|
|
416
|
+
self.current_avg_apy = float(asset.get("supply_apy") or 0.0) if asset else 0.0
|
|
417
|
+
return True
|
|
418
|
+
|
|
419
|
+
async def _get_assets_snapshot(self, force_refresh: bool = False) -> dict[str, Any]:
|
|
420
|
+
now = time.time()
|
|
421
|
+
if (
|
|
422
|
+
not force_refresh
|
|
423
|
+
and self._assets_snapshot is not None
|
|
424
|
+
and self._assets_snapshot_at is not None
|
|
425
|
+
and now - self._assets_snapshot_at <= self.ASSETS_SNAPSHOT_TTL_SECONDS
|
|
426
|
+
):
|
|
427
|
+
return self._assets_snapshot
|
|
428
|
+
|
|
429
|
+
async with self._assets_snapshot_lock:
|
|
430
|
+
now = time.time()
|
|
431
|
+
if (
|
|
432
|
+
not force_refresh
|
|
433
|
+
and self._assets_snapshot is not None
|
|
434
|
+
and self._assets_snapshot_at is not None
|
|
435
|
+
and now - self._assets_snapshot_at <= self.ASSETS_SNAPSHOT_TTL_SECONDS
|
|
436
|
+
):
|
|
437
|
+
return self._assets_snapshot
|
|
438
|
+
|
|
439
|
+
_, snapshot = await self.hyperlend_adapter.get_assets_view(
|
|
440
|
+
chain_id=self.hype_token_info.get("chain").get("id"),
|
|
441
|
+
user_address=self._get_strategy_wallet_address(),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
assets = snapshot.get("assets_view", {}).get("assets", [])
|
|
445
|
+
asset_map = {}
|
|
446
|
+
|
|
447
|
+
for asset in assets:
|
|
448
|
+
underlying = asset.get("underlying")
|
|
449
|
+
try:
|
|
450
|
+
checksum = Web3.to_checksum_address(underlying)
|
|
451
|
+
except Exception:
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
asset["underlying_checksum"] = checksum
|
|
455
|
+
symbol_raw = asset.get("symbol")
|
|
456
|
+
canonical = asset.get("symbol_canonical")
|
|
457
|
+
if not canonical:
|
|
458
|
+
canonical = (
|
|
459
|
+
self._normalize_symbol(symbol_raw)
|
|
460
|
+
if symbol_raw
|
|
461
|
+
else self._normalize_symbol(checksum)
|
|
462
|
+
)
|
|
463
|
+
asset["symbol_canonical"] = canonical
|
|
464
|
+
display_symbol = asset.get("symbol_display")
|
|
465
|
+
if not display_symbol:
|
|
466
|
+
display_symbol = symbol_raw or (
|
|
467
|
+
canonical.upper() if canonical else checksum
|
|
468
|
+
)
|
|
469
|
+
asset["symbol_display"] = display_symbol
|
|
470
|
+
key = symbol_raw or canonical
|
|
471
|
+
if key:
|
|
472
|
+
self.symbol_display_map.setdefault(str(key), display_symbol)
|
|
473
|
+
if canonical:
|
|
474
|
+
self.symbol_display_map.setdefault(canonical, display_symbol)
|
|
475
|
+
asset_map[checksum] = asset
|
|
476
|
+
|
|
477
|
+
snapshot["_by_underlying"] = asset_map
|
|
478
|
+
self._assets_snapshot = snapshot
|
|
479
|
+
self._assets_snapshot_at = time.time()
|
|
480
|
+
|
|
481
|
+
return snapshot
|
|
482
|
+
|
|
483
|
+
async def _has_supply_cap_headroom(
|
|
484
|
+
self, token: dict[str, Any], required_tokens: float
|
|
485
|
+
) -> bool:
|
|
486
|
+
checksum = self._token_checksum(token)
|
|
487
|
+
if not checksum:
|
|
488
|
+
return False
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
_, data = await self.hyperlend_adapter.get_stable_markets(
|
|
492
|
+
chain_id=self.hype_token_info.get("chain").get("id"),
|
|
493
|
+
required_underlying_tokens=required_tokens,
|
|
494
|
+
buffer_bps=self.SUPPLY_CAP_BUFFER_BPS,
|
|
495
|
+
min_buffer_tokens=self.SUPPLY_CAP_MIN_BUFFER_TOKENS,
|
|
496
|
+
is_stable_symbol=True,
|
|
497
|
+
)
|
|
498
|
+
markets = data.get("markets", {}) if isinstance(data, dict) else {}
|
|
499
|
+
except Exception:
|
|
500
|
+
return True
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
target_lower = Web3.to_checksum_address(checksum).lower()
|
|
504
|
+
except Exception:
|
|
505
|
+
target_lower = str(checksum).lower()
|
|
506
|
+
|
|
507
|
+
for addr in markets.keys():
|
|
508
|
+
try:
|
|
509
|
+
if Web3.to_checksum_address(addr).lower() == target_lower:
|
|
510
|
+
return True
|
|
511
|
+
except Exception:
|
|
512
|
+
if str(addr).lower() == target_lower:
|
|
513
|
+
return True
|
|
514
|
+
return False
|
|
515
|
+
|
|
516
|
+
async def _get_lent_positions(self, snapshot=None) -> dict[str, dict[str, Any]]:
|
|
517
|
+
if not snapshot:
|
|
518
|
+
snapshot = await self._get_assets_snapshot()
|
|
519
|
+
assets = snapshot.get("assets_view", {}).get("assets", None)
|
|
520
|
+
|
|
521
|
+
if not assets:
|
|
522
|
+
return {}
|
|
523
|
+
|
|
524
|
+
positions = {}
|
|
525
|
+
for asset in assets:
|
|
526
|
+
try:
|
|
527
|
+
checksum = asset.get("underlying_checksum") or Web3.to_checksum_address(
|
|
528
|
+
asset.get("underlying")
|
|
529
|
+
)
|
|
530
|
+
except Exception:
|
|
531
|
+
logger.info(f"Error getting checksum for asset: {asset}")
|
|
532
|
+
continue
|
|
533
|
+
|
|
534
|
+
supply = float(asset.get("supply", 0.0) or 0.0)
|
|
535
|
+
if supply <= 0.0:
|
|
536
|
+
logger.info(f"Supply is 0 for asset: {asset}")
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
success, token = await self.token_adapter.get_token(checksum)
|
|
541
|
+
if not success or not isinstance(token, dict):
|
|
542
|
+
logger.info(f"Error getting token for asset: {asset}")
|
|
543
|
+
continue
|
|
544
|
+
except Exception:
|
|
545
|
+
logger.info(f"Error getting token for asset: {asset}")
|
|
546
|
+
continue
|
|
547
|
+
|
|
548
|
+
amount_wei = supply * (10 ** token.get("decimals", 0))
|
|
549
|
+
if amount_wei <= 0:
|
|
550
|
+
logger.info(f"Amount wei is 0 for asset: {asset}")
|
|
551
|
+
continue
|
|
552
|
+
|
|
553
|
+
positions[checksum] = {
|
|
554
|
+
"token": token,
|
|
555
|
+
"amount_wei": amount_wei,
|
|
556
|
+
"asset": asset,
|
|
557
|
+
}
|
|
558
|
+
return positions
|
|
559
|
+
|
|
560
|
+
def _normalize_symbol(self, symbol: str) -> str:
|
|
561
|
+
if symbol is None:
|
|
562
|
+
return ""
|
|
563
|
+
|
|
564
|
+
normalized = unicodedata.normalize("NFKD", str(symbol)).translate(
|
|
565
|
+
SYMBOL_TRANSLATION_TABLE
|
|
566
|
+
)
|
|
567
|
+
ascii_only = normalized.encode("ascii", "ignore").decode("ascii")
|
|
568
|
+
filtered = "".join(ch for ch in ascii_only if ch.isalnum())
|
|
569
|
+
if filtered:
|
|
570
|
+
return filtered.lower()
|
|
571
|
+
return str(symbol).lower()
|
|
572
|
+
|
|
573
|
+
def _is_stable_symbol(self, symbol: str) -> bool:
|
|
574
|
+
if not symbol:
|
|
575
|
+
return False
|
|
576
|
+
symbol_upper = symbol.upper()
|
|
577
|
+
stable_keywords = ["USD", "USDC", "USDT", "USDP", "USDD", "USDS", "DAI", "USKB"]
|
|
578
|
+
return any(keyword in symbol_upper for keyword in stable_keywords)
|
|
579
|
+
|
|
580
|
+
def _invalidate_assets_snapshot(self) -> None:
|
|
581
|
+
self._assets_snapshot = None
|
|
582
|
+
self._assets_snapshot_at = None
|
|
583
|
+
|
|
584
|
+
async def _execute_swap(
|
|
585
|
+
self,
|
|
586
|
+
from_token_info: dict[str, Any],
|
|
587
|
+
to_token_info: dict[str, Any],
|
|
588
|
+
amount_wei: int,
|
|
589
|
+
*,
|
|
590
|
+
slippage: float = DEFAULT_SLIPPAGE,
|
|
591
|
+
) -> str | None:
|
|
592
|
+
if amount_wei <= 0:
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
from_token_id = (
|
|
596
|
+
from_token_info.get("token_id")
|
|
597
|
+
or f"{from_token_info.get('asset_id')}-{self.hype_token_info.get('chain').get('code')}"
|
|
598
|
+
)
|
|
599
|
+
to_token_id = (
|
|
600
|
+
to_token_info.get("token_id")
|
|
601
|
+
or f"{to_token_info.get('asset_id')}-{self.hype_token_info.get('chain').get('code')}"
|
|
602
|
+
)
|
|
603
|
+
if not from_token_id or not to_token_id:
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
from_address = self._get_token_address(from_token_info, chain_code="hyperevm")
|
|
607
|
+
to_address = self._get_token_address(to_token_info, chain_code="hyperevm")
|
|
608
|
+
if not from_address or not to_address:
|
|
609
|
+
return None
|
|
610
|
+
|
|
611
|
+
from_symbol = from_token_info.get("symbol")
|
|
612
|
+
to_symbol = to_token_info.get("symbol")
|
|
613
|
+
|
|
614
|
+
strategy_address = self._get_strategy_wallet_address()
|
|
615
|
+
|
|
616
|
+
retries = 7
|
|
617
|
+
while retries > 0:
|
|
618
|
+
try:
|
|
619
|
+
from_decimals = from_token_info.get("decimals") or 18
|
|
620
|
+
amount_wei_str = str(amount_wei)
|
|
621
|
+
# TODO: await favourable fees
|
|
622
|
+
(
|
|
623
|
+
result,
|
|
624
|
+
tx_data,
|
|
625
|
+
) = await self.brap_adapter.swap_from_token_ids(
|
|
626
|
+
from_token_id=from_token_id,
|
|
627
|
+
to_token_id=to_token_id,
|
|
628
|
+
from_address=strategy_address,
|
|
629
|
+
amount=amount_wei_str,
|
|
630
|
+
slippage=slippage,
|
|
631
|
+
strategy_name=self.name,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
if not result:
|
|
635
|
+
error_msg = str(tx_data) if isinstance(tx_data, str) else ""
|
|
636
|
+
|
|
637
|
+
if (
|
|
638
|
+
"Transaction did not land" in error_msg
|
|
639
|
+
or "Broadcast fail" in error_msg.lower()
|
|
640
|
+
or "broadcast" in error_msg.lower()
|
|
641
|
+
and "fail" in error_msg.lower()
|
|
642
|
+
):
|
|
643
|
+
retries -= 1
|
|
644
|
+
await asyncio.sleep(3.0)
|
|
645
|
+
continue
|
|
646
|
+
else:
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
self._invalidate_assets_snapshot()
|
|
650
|
+
human = float(amount_wei) / (10**from_decimals)
|
|
651
|
+
return f"Swapped {human:.4f} {from_symbol} → {to_symbol}"
|
|
652
|
+
|
|
653
|
+
except Exception:
|
|
654
|
+
retries -= 1
|
|
655
|
+
if retries > 0:
|
|
656
|
+
await asyncio.sleep(3.0)
|
|
657
|
+
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
def _get_token_address(
|
|
661
|
+
self, token: dict[str, Any] | None, chain_code: str = "hyperevm"
|
|
662
|
+
) -> str | None:
|
|
663
|
+
"""
|
|
664
|
+
Extract token address from various token data structures.
|
|
665
|
+
|
|
666
|
+
Handles:
|
|
667
|
+
1. Top-level 'address' field (e.g., hype_token_info)
|
|
668
|
+
2. 'addresses' dict with chain_code key (e.g., addresses: {'hyperevm': '0x...'})
|
|
669
|
+
3. 'chain_addresses' dict with chain_code key (e.g., chain_addresses: {'hyperevm': {'address': '0x...'}})
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
token: Token dictionary with address information
|
|
673
|
+
chain_code: Chain code to look up in nested structures (default: 'hyperevm')
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Token address string or None if not found
|
|
677
|
+
"""
|
|
678
|
+
if not token:
|
|
679
|
+
return None
|
|
680
|
+
|
|
681
|
+
address = token.get("address")
|
|
682
|
+
if address:
|
|
683
|
+
return str(address)
|
|
684
|
+
|
|
685
|
+
addresses = token.get("addresses")
|
|
686
|
+
if isinstance(addresses, dict):
|
|
687
|
+
address = addresses.get(chain_code)
|
|
688
|
+
if address:
|
|
689
|
+
return str(address)
|
|
690
|
+
if addresses:
|
|
691
|
+
first_address = next(iter(addresses.values()), None)
|
|
692
|
+
if first_address:
|
|
693
|
+
return str(first_address)
|
|
694
|
+
|
|
695
|
+
chain_addresses = token.get("chain_addresses")
|
|
696
|
+
if isinstance(chain_addresses, dict):
|
|
697
|
+
chain_info = chain_addresses.get(chain_code)
|
|
698
|
+
if isinstance(chain_info, dict):
|
|
699
|
+
address = chain_info.get("address")
|
|
700
|
+
if address:
|
|
701
|
+
return str(address)
|
|
702
|
+
if chain_addresses:
|
|
703
|
+
first_chain_info = next(iter(chain_addresses.values()), None)
|
|
704
|
+
if isinstance(first_chain_info, dict):
|
|
705
|
+
address = first_chain_info.get("address")
|
|
706
|
+
if address:
|
|
707
|
+
return str(address)
|
|
708
|
+
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
def _token_checksum(self, token: dict[str, Any] | None) -> str | None:
|
|
712
|
+
address = self._get_token_address(token)
|
|
713
|
+
if not address:
|
|
714
|
+
return None
|
|
715
|
+
try:
|
|
716
|
+
return Web3.to_checksum_address(address)
|
|
717
|
+
except Exception:
|
|
718
|
+
return None
|
|
719
|
+
|
|
720
|
+
async def withdraw(self, amount: float | None = None) -> StatusTuple:
|
|
721
|
+
messages = []
|
|
722
|
+
|
|
723
|
+
active_token = self.current_token
|
|
724
|
+
if not active_token:
|
|
725
|
+
await self._hydrate_position_from_chain()
|
|
726
|
+
active_token = self.current_token
|
|
727
|
+
|
|
728
|
+
amount_wei = 0
|
|
729
|
+
snapshot = await self._get_assets_snapshot(force_refresh=True)
|
|
730
|
+
asset_map = (
|
|
731
|
+
snapshot.get("_by_underlying", {}) if isinstance(snapshot, dict) else {}
|
|
732
|
+
)
|
|
733
|
+
if active_token:
|
|
734
|
+
checksum = self._token_checksum(active_token)
|
|
735
|
+
asset = asset_map.get(checksum) if checksum else None
|
|
736
|
+
lent_balance = float(asset.get("supply", 0.0)) if asset else 0.0
|
|
737
|
+
if lent_balance > 0:
|
|
738
|
+
amount_wei = float(lent_balance) * (10 ** active_token.get("decimals"))
|
|
739
|
+
chain_code = self.hype_token_info.get("chain", {}).get(
|
|
740
|
+
"code", "hyperevm"
|
|
741
|
+
)
|
|
742
|
+
underlying_token_address = self._get_token_address(
|
|
743
|
+
active_token, chain_code
|
|
744
|
+
)
|
|
745
|
+
if not underlying_token_address:
|
|
746
|
+
messages.append(
|
|
747
|
+
f"Failed to resolve token address for {active_token.get('symbol', 'unknown')} on {chain_code}; skipping unlend"
|
|
748
|
+
)
|
|
749
|
+
else:
|
|
750
|
+
# TODO: await favourable fees
|
|
751
|
+
status, message = await self.hyperlend_adapter.unlend(
|
|
752
|
+
underlying_token=underlying_token_address,
|
|
753
|
+
qty=int(amount_wei),
|
|
754
|
+
chain_id=int(self.hype_token_info.get("chain").get("id")),
|
|
755
|
+
native=False,
|
|
756
|
+
)
|
|
757
|
+
self._invalidate_assets_snapshot()
|
|
758
|
+
self._invalidate_assets_snapshot()
|
|
759
|
+
else:
|
|
760
|
+
messages.append(
|
|
761
|
+
"No active HyperLend position found; sweeping idle balances."
|
|
762
|
+
)
|
|
763
|
+
else:
|
|
764
|
+
messages.append("No HyperLend position detected; sweeping idle balances.")
|
|
765
|
+
|
|
766
|
+
sweep_actions = await self._swap_residual_balances_to_token(
|
|
767
|
+
self.usdt_token_info
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
try:
|
|
771
|
+
_, total_usdt_wei = await self.balance_adapter.get_balance(
|
|
772
|
+
token_id=self.usdt_token_info.get("token_id"),
|
|
773
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
774
|
+
)
|
|
775
|
+
except Exception:
|
|
776
|
+
total_usdt_wei = 0
|
|
777
|
+
|
|
778
|
+
if total_usdt_wei and total_usdt_wei > 0:
|
|
779
|
+
total_usdt = float(total_usdt_wei) / (
|
|
780
|
+
10 ** self.usdt_token_info.get("decimals", 18)
|
|
781
|
+
)
|
|
782
|
+
(
|
|
783
|
+
transfer_success,
|
|
784
|
+
transfer_message,
|
|
785
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
786
|
+
self.usdt_token_info.get("token_id"),
|
|
787
|
+
total_usdt,
|
|
788
|
+
strategy_name=self.name,
|
|
789
|
+
)
|
|
790
|
+
if transfer_success:
|
|
791
|
+
messages.append(
|
|
792
|
+
f"Returned {total_usdt:.2f} {self.usdt_token_info.get('symbol')} from strategy wallet to main wallet"
|
|
793
|
+
)
|
|
794
|
+
else:
|
|
795
|
+
messages.append(
|
|
796
|
+
"Returned USDT0 to ledger but on-chain transfer failed; treating as withdrawn for simulation"
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
try:
|
|
800
|
+
_, total_hype_wei = await self.balance_adapter.get_balance(
|
|
801
|
+
token_id=self.hype_token_info.get("token_id"),
|
|
802
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
803
|
+
)
|
|
804
|
+
except Exception:
|
|
805
|
+
total_hype_wei = 0
|
|
806
|
+
|
|
807
|
+
if total_hype_wei and total_hype_wei > 0:
|
|
808
|
+
total_hype = float(total_hype_wei) / (
|
|
809
|
+
10 ** self.hype_token_info.get("decimals", 18)
|
|
810
|
+
)
|
|
811
|
+
total_hype = total_hype * 0.9
|
|
812
|
+
(
|
|
813
|
+
transfer_success,
|
|
814
|
+
transfer_message,
|
|
815
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
816
|
+
self.hype_token_info.get("token_id"),
|
|
817
|
+
total_hype,
|
|
818
|
+
strategy_name=self.name,
|
|
819
|
+
)
|
|
820
|
+
if transfer_success:
|
|
821
|
+
messages.append(
|
|
822
|
+
f"Returned {total_hype:.2f} {self.hype_token_info.get('symbol')} from strategy wallet to main wallet"
|
|
823
|
+
)
|
|
824
|
+
else:
|
|
825
|
+
messages.append(
|
|
826
|
+
"Returned HYPE to ledger but on-chain transfer failed; treating as withdrawn for simulation"
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
if sweep_actions:
|
|
830
|
+
messages.append(f"Residual sweeps: {'; '.join(sweep_actions)}.")
|
|
831
|
+
|
|
832
|
+
if not messages:
|
|
833
|
+
messages.append("Withdrawal complete; no balances detected to transfer")
|
|
834
|
+
|
|
835
|
+
self.current_token = None
|
|
836
|
+
self.current_symbol = None
|
|
837
|
+
self.current_avg_apy = 0.0
|
|
838
|
+
self.kept_hype_tokens = 0.0
|
|
839
|
+
|
|
840
|
+
return (True, ". ".join(messages))
|
|
841
|
+
|
|
842
|
+
async def _swap_residual_balances_to_token(
|
|
843
|
+
self, token_info: dict[str, Any], include_native: bool = False
|
|
844
|
+
) -> list[str]:
|
|
845
|
+
snapshot = await self._get_assets_snapshot(force_refresh=True)
|
|
846
|
+
balances = await self._wallet_balances_from_snapshot(snapshot)
|
|
847
|
+
if not balances:
|
|
848
|
+
return []
|
|
849
|
+
actions = []
|
|
850
|
+
target_checksum = self._token_checksum(token_info)
|
|
851
|
+
for checksum, entry in balances.items():
|
|
852
|
+
if checksum == "native":
|
|
853
|
+
if not include_native:
|
|
854
|
+
continue
|
|
855
|
+
else:
|
|
856
|
+
if checksum == target_checksum:
|
|
857
|
+
continue
|
|
858
|
+
asset = entry.get("asset")
|
|
859
|
+
if not asset or not asset.get("is_stablecoin"):
|
|
860
|
+
continue
|
|
861
|
+
balance_wei = int(entry.get("wei") or 0)
|
|
862
|
+
if balance_wei <= 0:
|
|
863
|
+
continue
|
|
864
|
+
token = entry.get("token")
|
|
865
|
+
if not token:
|
|
866
|
+
continue
|
|
867
|
+
token["address"] = checksum
|
|
868
|
+
min_amount = self._amount_to_wei(
|
|
869
|
+
token, Decimal(str(self.MIN_STABLE_SWAP_TOKENS))
|
|
870
|
+
)
|
|
871
|
+
if balance_wei <= min_amount:
|
|
872
|
+
continue
|
|
873
|
+
|
|
874
|
+
try:
|
|
875
|
+
swap_action = await self._execute_swap(
|
|
876
|
+
from_token_info=token,
|
|
877
|
+
to_token_info=token_info,
|
|
878
|
+
amount_wei=balance_wei,
|
|
879
|
+
)
|
|
880
|
+
if swap_action:
|
|
881
|
+
actions.append(swap_action)
|
|
882
|
+
continue
|
|
883
|
+
except Exception:
|
|
884
|
+
continue
|
|
885
|
+
|
|
886
|
+
# TODO: untested past this point
|
|
887
|
+
try:
|
|
888
|
+
try:
|
|
889
|
+
decimals = int(token.get("decimals", 18))
|
|
890
|
+
except (TypeError, ValueError):
|
|
891
|
+
decimals = 18
|
|
892
|
+
amount_tokens = float(balance_wei) / (10**decimals)
|
|
893
|
+
(
|
|
894
|
+
success,
|
|
895
|
+
message,
|
|
896
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
897
|
+
token_id=token_info.get("token_id"),
|
|
898
|
+
amount=amount_tokens,
|
|
899
|
+
strategy_name=self.name,
|
|
900
|
+
)
|
|
901
|
+
except Exception:
|
|
902
|
+
continue
|
|
903
|
+
|
|
904
|
+
if success:
|
|
905
|
+
actions.append(
|
|
906
|
+
f"Transferred {amount_tokens:.4f} {token.symbol} to main wallet"
|
|
907
|
+
)
|
|
908
|
+
if actions:
|
|
909
|
+
self._invalidate_assets_snapshot()
|
|
910
|
+
|
|
911
|
+
return actions
|
|
912
|
+
|
|
913
|
+
async def update(self) -> StatusTuple:
|
|
914
|
+
"""Rebalance or update positions."""
|
|
915
|
+
|
|
916
|
+
await self._hydrate_position_from_chain()
|
|
917
|
+
|
|
918
|
+
redeploy_tokens = await self._estimate_redeploy_tokens()
|
|
919
|
+
idle_tokens = await self._get_idle_tokens()
|
|
920
|
+
|
|
921
|
+
if idle_tokens > 0.1:
|
|
922
|
+
total_required = (redeploy_tokens or 0.0) + idle_tokens
|
|
923
|
+
best_candidate = await self._select_best_stable_asset(
|
|
924
|
+
required_underlying_tokens=total_required,
|
|
925
|
+
operation="deposit",
|
|
926
|
+
allow_rotation_without_current=False,
|
|
927
|
+
)
|
|
928
|
+
if best_candidate is None:
|
|
929
|
+
token_symbol = (
|
|
930
|
+
self._display_symbol(getattr(self, "current_symbol", None))
|
|
931
|
+
if self.current_token
|
|
932
|
+
else "idle"
|
|
933
|
+
)
|
|
934
|
+
return (
|
|
935
|
+
True,
|
|
936
|
+
f"Idle balance ({idle_tokens:.4f} {token_symbol}) remains; no HyperLend market has sufficient capacity.",
|
|
937
|
+
False,
|
|
938
|
+
)
|
|
939
|
+
best_token, best_symbol, best_hourly = best_candidate
|
|
940
|
+
target_apy = self._hourly_to_apy(best_hourly)
|
|
941
|
+
reserve_wei = self.GAS_MAXIMUM * (
|
|
942
|
+
10 ** self.hype_token_info.get("decimals")
|
|
943
|
+
)
|
|
944
|
+
display_symbol = self._display_symbol(best_symbol)
|
|
945
|
+
|
|
946
|
+
(
|
|
947
|
+
actions,
|
|
948
|
+
total_target,
|
|
949
|
+
_,
|
|
950
|
+
kept_hype,
|
|
951
|
+
) = await self._allocate_to_target(
|
|
952
|
+
best_token,
|
|
953
|
+
target_symbol=display_symbol,
|
|
954
|
+
target_apy=target_apy,
|
|
955
|
+
hype_reserve_wei=reserve_wei,
|
|
956
|
+
lend_operation="deposit",
|
|
957
|
+
)
|
|
958
|
+
message = (
|
|
959
|
+
f"Redeployed idle funds into {display_symbol} (~{target_apy:.2%} APY). "
|
|
960
|
+
f"Current lent balance {total_target:.4f} {display_symbol}."
|
|
961
|
+
)
|
|
962
|
+
if actions:
|
|
963
|
+
message = f"{message} Actions: {'; '.join(actions)}."
|
|
964
|
+
message = f"{message} HYPE buffer at {self.kept_hype_tokens:.2f} tokens."
|
|
965
|
+
return (True, message, True)
|
|
966
|
+
|
|
967
|
+
required_tokens = (
|
|
968
|
+
redeploy_tokens if redeploy_tokens and redeploy_tokens > 0 else None
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
best_candidate = await self._select_best_stable_asset(
|
|
972
|
+
required_underlying_tokens=required_tokens,
|
|
973
|
+
operation="update",
|
|
974
|
+
allow_rotation_without_current=True,
|
|
975
|
+
)
|
|
976
|
+
if best_candidate is None:
|
|
977
|
+
return (True, "No optimal HyperLend market identified.", False)
|
|
978
|
+
|
|
979
|
+
best_token, best_symbol, best_hourly = best_candidate
|
|
980
|
+
target_apy = self._hourly_to_apy(best_hourly)
|
|
981
|
+
current_checksum = self._token_checksum(self.current_token)
|
|
982
|
+
best_checksum = self._token_checksum(best_token)
|
|
983
|
+
display_symbol = self._display_symbol(best_symbol)
|
|
984
|
+
self.symbol_display_map.setdefault(best_symbol, display_symbol)
|
|
985
|
+
|
|
986
|
+
if current_checksum and best_checksum and current_checksum == best_checksum:
|
|
987
|
+
message = (
|
|
988
|
+
f"Maintained allocation in {display_symbol} (~{target_apy:.2%} APY)."
|
|
989
|
+
)
|
|
990
|
+
if redeploy_tokens:
|
|
991
|
+
message = (
|
|
992
|
+
f"{message} Existing position remains optimal versus alternatives."
|
|
993
|
+
)
|
|
994
|
+
return (True, message, False)
|
|
995
|
+
|
|
996
|
+
reserve_wei = self.GAS_MAXIMUM * (10 ** self.hype_token_info.get("decimals"))
|
|
997
|
+
previous_apy = float(self.current_avg_apy or 0.0)
|
|
998
|
+
delta_apy = target_apy - previous_apy if previous_apy else target_apy
|
|
999
|
+
|
|
1000
|
+
policy_mode = (
|
|
1001
|
+
self.rotation_policy
|
|
1002
|
+
if self.rotation_policy
|
|
1003
|
+
else self.ROTATION_POLICY.lower()
|
|
1004
|
+
)
|
|
1005
|
+
summary_df = (
|
|
1006
|
+
self.last_summary
|
|
1007
|
+
if isinstance(self.last_summary, pd.DataFrame)
|
|
1008
|
+
else pd.DataFrame()
|
|
1009
|
+
)
|
|
1010
|
+
hys_hours = max(
|
|
1011
|
+
1,
|
|
1012
|
+
int(
|
|
1013
|
+
self.hys_dwell_hours
|
|
1014
|
+
if self.hys_dwell_hours
|
|
1015
|
+
else self.HYSTERESIS_DWELL_HOURS
|
|
1016
|
+
),
|
|
1017
|
+
)
|
|
1018
|
+
hys_z = float(self.hys_z if self.hys_z else self.HYSTERESIS_Z)
|
|
1019
|
+
rotation_tx_cost = float(
|
|
1020
|
+
self.rotation_tx_cost if self.rotation_tx_cost else self.ROTATION_TX_COST
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
short_circuit_triggered = (
|
|
1024
|
+
self.APY_SHORT_CIRCUIT_THRESHOLD is not None
|
|
1025
|
+
and delta_apy > self.APY_SHORT_CIRCUIT_THRESHOLD
|
|
1026
|
+
)
|
|
1027
|
+
deny_reasons = []
|
|
1028
|
+
rotation_reason = None
|
|
1029
|
+
should_rotate = False
|
|
1030
|
+
|
|
1031
|
+
if short_circuit_triggered:
|
|
1032
|
+
should_rotate = True
|
|
1033
|
+
rotation_reason = f"Short-circuit triggered by {delta_apy:.2%} APY edge."
|
|
1034
|
+
elif policy_mode == "hysteresis":
|
|
1035
|
+
if summary_df.empty:
|
|
1036
|
+
deny_reasons.append(
|
|
1037
|
+
"Hysteresis check skipped: no tournament summary available."
|
|
1038
|
+
)
|
|
1039
|
+
else:
|
|
1040
|
+
best_row_df = summary_df.loc[summary_df["asset"] == best_symbol]
|
|
1041
|
+
cur_row_df = (
|
|
1042
|
+
summary_df.loc[summary_df["asset"] == self.current_symbol]
|
|
1043
|
+
if self.current_symbol
|
|
1044
|
+
else pd.DataFrame()
|
|
1045
|
+
)
|
|
1046
|
+
if best_row_df.empty:
|
|
1047
|
+
deny_reasons.append(
|
|
1048
|
+
f"Unable to locate {display_symbol} in tournament summary."
|
|
1049
|
+
)
|
|
1050
|
+
else:
|
|
1051
|
+
best_row = best_row_df.iloc[0]
|
|
1052
|
+
best_E = float(best_row.get("mean", 0.0))
|
|
1053
|
+
best_SD = float(best_row.get("std", 0.0))
|
|
1054
|
+
|
|
1055
|
+
if not cur_row_df.empty:
|
|
1056
|
+
cur_row = cur_row_df.iloc[0]
|
|
1057
|
+
cur_E = float(cur_row.get("mean", 0.0))
|
|
1058
|
+
cur_SD = float(cur_row.get("std", 0.0))
|
|
1059
|
+
else:
|
|
1060
|
+
cur_hourly = float(
|
|
1061
|
+
self._apy_to_hourly(previous_apy)
|
|
1062
|
+
if previous_apy
|
|
1063
|
+
else best_hourly
|
|
1064
|
+
)
|
|
1065
|
+
cur_E = math.log1p(cur_hourly) * self.HORIZON_HOURS
|
|
1066
|
+
cur_SD = 0.0
|
|
1067
|
+
|
|
1068
|
+
edge_cum_log = best_E - cur_E
|
|
1069
|
+
sigma_delta = math.sqrt(
|
|
1070
|
+
max(0.0, best_SD * best_SD + cur_SD * cur_SD)
|
|
1071
|
+
)
|
|
1072
|
+
cost_log_mag = abs(math.log1p(-rotation_tx_cost))
|
|
1073
|
+
amortized_cost = cost_log_mag * (
|
|
1074
|
+
self.HORIZON_HOURS / max(1.0, float(hys_hours))
|
|
1075
|
+
)
|
|
1076
|
+
hurdle = amortized_cost + hys_z * sigma_delta
|
|
1077
|
+
|
|
1078
|
+
if edge_cum_log > hurdle:
|
|
1079
|
+
should_rotate = True
|
|
1080
|
+
rotation_reason = (
|
|
1081
|
+
f"Hysteresis edge {edge_cum_log:.4f} > hurdle {hurdle:.4f}."
|
|
1082
|
+
)
|
|
1083
|
+
else:
|
|
1084
|
+
deny_reasons.append(
|
|
1085
|
+
f"Hysteresis band holds: edge {edge_cum_log:.4f} ≤ hurdle {hurdle:.4f}."
|
|
1086
|
+
)
|
|
1087
|
+
else:
|
|
1088
|
+
rotation_allowed = True
|
|
1089
|
+
if previous_apy and delta_apy <= self.APY_REBALANCE_THRESHOLD:
|
|
1090
|
+
rotation_allowed = False
|
|
1091
|
+
deny_reasons.append(
|
|
1092
|
+
f"APY improvement ({delta_apy:.2%}) below {self.APY_REBALANCE_THRESHOLD:.2%} threshold."
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
last_rotation = await self._get_last_rotation_time(
|
|
1096
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
1097
|
+
)
|
|
1098
|
+
cooldown_notice = None
|
|
1099
|
+
if rotation_allowed and last_rotation is not None:
|
|
1100
|
+
elapsed = timezone.now() - last_rotation
|
|
1101
|
+
if elapsed < self.ROTATION_COOLDOWN:
|
|
1102
|
+
rotation_allowed = False
|
|
1103
|
+
remaining_hours = max(
|
|
1104
|
+
0, (self.ROTATION_COOLDOWN - elapsed).total_seconds() / 3600
|
|
1105
|
+
)
|
|
1106
|
+
cooldown_notice = (
|
|
1107
|
+
f"Rotation cooldown active; ~{remaining_hours:.1f}h remaining."
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
if rotation_allowed:
|
|
1111
|
+
should_rotate = True
|
|
1112
|
+
if previous_apy:
|
|
1113
|
+
rotation_reason = (
|
|
1114
|
+
f"APY edge {delta_apy:.2%} cleared threshold and cooldown."
|
|
1115
|
+
)
|
|
1116
|
+
else:
|
|
1117
|
+
rotation_reason = "Initial deployment into best-performing asset."
|
|
1118
|
+
else:
|
|
1119
|
+
if cooldown_notice:
|
|
1120
|
+
deny_reasons.append(cooldown_notice)
|
|
1121
|
+
|
|
1122
|
+
if not should_rotate:
|
|
1123
|
+
current_display = (
|
|
1124
|
+
self._display_symbol(self.current_symbol)
|
|
1125
|
+
if self.current_symbol
|
|
1126
|
+
else display_symbol
|
|
1127
|
+
)
|
|
1128
|
+
baseline_apy = previous_apy if previous_apy else target_apy
|
|
1129
|
+
message_parts = []
|
|
1130
|
+
if deny_reasons:
|
|
1131
|
+
message_parts.append("NO UPDATE was performed.")
|
|
1132
|
+
message_parts.append(" ".join(deny_reasons))
|
|
1133
|
+
|
|
1134
|
+
if current_checksum:
|
|
1135
|
+
message_parts.append(
|
|
1136
|
+
f"Maintained allocation in {current_display} (~{baseline_apy:.2%} APY)."
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
if best_checksum:
|
|
1140
|
+
message_parts.append(
|
|
1141
|
+
f"The best symbol would be: {display_symbol} at ~{target_apy:.2%} APY."
|
|
1142
|
+
)
|
|
1143
|
+
if policy_mode == "hysteresis":
|
|
1144
|
+
message_parts.append(
|
|
1145
|
+
f"Hysteresis parameters: dwell={hys_hours}h, z={hys_z:.2f}."
|
|
1146
|
+
)
|
|
1147
|
+
return (
|
|
1148
|
+
True,
|
|
1149
|
+
" ".join(part for part in message_parts if part).strip(),
|
|
1150
|
+
False,
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
actions, total_target, _, kept_hype = await self._allocate_to_target(
|
|
1154
|
+
best_token,
|
|
1155
|
+
target_symbol=display_symbol,
|
|
1156
|
+
target_apy=target_apy,
|
|
1157
|
+
hype_reserve_wei=reserve_wei,
|
|
1158
|
+
lend_operation="update",
|
|
1159
|
+
)
|
|
1160
|
+
self.kept_hype_tokens = kept_hype
|
|
1161
|
+
|
|
1162
|
+
base_message = (
|
|
1163
|
+
f"Aligned supplies into {display_symbol} (~{target_apy:.2%} APY). "
|
|
1164
|
+
f"Current lent balance {total_target:.4f} {display_symbol}."
|
|
1165
|
+
)
|
|
1166
|
+
if rotation_reason:
|
|
1167
|
+
base_message = f"{base_message} {rotation_reason}"
|
|
1168
|
+
elif policy_mode == "hysteresis":
|
|
1169
|
+
base_message = f"{base_message} Hysteresis rotation with dwell={hys_hours}h, z={hys_z:.2f}."
|
|
1170
|
+
|
|
1171
|
+
should_notify_user = False
|
|
1172
|
+
if actions:
|
|
1173
|
+
base_message = f"{base_message} Actions: {'; '.join(actions)}."
|
|
1174
|
+
should_notify_user = True
|
|
1175
|
+
else:
|
|
1176
|
+
base_message = f"{base_message} No rebalancing required."
|
|
1177
|
+
|
|
1178
|
+
base_message = (
|
|
1179
|
+
f"{base_message} HYPE buffer at {self.kept_hype_tokens:.2f} tokens."
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
return (True, base_message, should_notify_user)
|
|
1183
|
+
|
|
1184
|
+
async def _allocate_to_target(
|
|
1185
|
+
self,
|
|
1186
|
+
target_token: dict[str, Any],
|
|
1187
|
+
*,
|
|
1188
|
+
target_symbol: str,
|
|
1189
|
+
target_apy: float,
|
|
1190
|
+
hype_reserve_wei: int,
|
|
1191
|
+
lend_operation: Literal["deposit", "update"] = "update",
|
|
1192
|
+
) -> tuple[list[dict[str, Any]], float, float, float]:
|
|
1193
|
+
actions = []
|
|
1194
|
+
actions.extend(await self._unwind_other_lends(target_token))
|
|
1195
|
+
|
|
1196
|
+
align_actions, kept_hype = await self._align_wallet_balances(
|
|
1197
|
+
target_token, hype_reserve_wei=hype_reserve_wei
|
|
1198
|
+
)
|
|
1199
|
+
actions.extend(align_actions)
|
|
1200
|
+
|
|
1201
|
+
lent_tokens = await self._lend_available_balance(
|
|
1202
|
+
target_token, operation=lend_operation
|
|
1203
|
+
)
|
|
1204
|
+
if lent_tokens > 0:
|
|
1205
|
+
actions.append(f"Lent {lent_tokens:.4f} {target_symbol}")
|
|
1206
|
+
|
|
1207
|
+
try:
|
|
1208
|
+
target_checksum = Web3.to_checksum_address(
|
|
1209
|
+
self._get_token_address(target_token)
|
|
1210
|
+
)
|
|
1211
|
+
except Exception:
|
|
1212
|
+
target_checksum = None
|
|
1213
|
+
|
|
1214
|
+
total_target = 0.0
|
|
1215
|
+
if target_checksum:
|
|
1216
|
+
new_positions = await self._get_lent_positions()
|
|
1217
|
+
total_target_wei = sum(
|
|
1218
|
+
entry["amount_wei"]
|
|
1219
|
+
for entry in new_positions.values()
|
|
1220
|
+
if Web3.to_checksum_address(entry["asset"]["underlying"])
|
|
1221
|
+
== target_checksum
|
|
1222
|
+
)
|
|
1223
|
+
total_target = float(total_target_wei) / (
|
|
1224
|
+
10 ** target_token.get("decimals", 18)
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
self.current_token = target_token
|
|
1228
|
+
self.current_symbol = target_token.get("symbol")
|
|
1229
|
+
self.current_avg_apy = target_apy
|
|
1230
|
+
self.kept_hype_tokens = kept_hype
|
|
1231
|
+
|
|
1232
|
+
await self._hydrate_position_from_chain()
|
|
1233
|
+
|
|
1234
|
+
return actions, total_target, lent_tokens, kept_hype
|
|
1235
|
+
|
|
1236
|
+
async def _lend_available_balance(
|
|
1237
|
+
self,
|
|
1238
|
+
target_token: dict[str, Any],
|
|
1239
|
+
*,
|
|
1240
|
+
operation: Literal["deposit", "update"] = "update",
|
|
1241
|
+
) -> float:
|
|
1242
|
+
if not self._get_token_address(target_token):
|
|
1243
|
+
return 0.0
|
|
1244
|
+
|
|
1245
|
+
snapshot = await self._get_assets_snapshot(force_refresh=True)
|
|
1246
|
+
balances = await self._wallet_balances_from_snapshot(snapshot)
|
|
1247
|
+
|
|
1248
|
+
target_checksum = self._token_checksum(target_token)
|
|
1249
|
+
if not target_checksum:
|
|
1250
|
+
return 0.0
|
|
1251
|
+
|
|
1252
|
+
entry = balances.get(target_checksum)
|
|
1253
|
+
original_amount_wei = int(entry.get("wei") or 0) if entry else 0
|
|
1254
|
+
if original_amount_wei <= 0:
|
|
1255
|
+
return 0.0
|
|
1256
|
+
|
|
1257
|
+
amount_wei = int(original_amount_wei)
|
|
1258
|
+
if operation == "deposit":
|
|
1259
|
+
amount_tokens = float(amount_wei) / (10 ** target_token.get("decimals"))
|
|
1260
|
+
has_headroom = await self._has_supply_cap_headroom(
|
|
1261
|
+
target_token, amount_tokens
|
|
1262
|
+
)
|
|
1263
|
+
if not has_headroom:
|
|
1264
|
+
return 0.0
|
|
1265
|
+
|
|
1266
|
+
# TODO: await favourable fees
|
|
1267
|
+
max_attempts = 3
|
|
1268
|
+
for attempt in range(max_attempts):
|
|
1269
|
+
try:
|
|
1270
|
+
result, message = await self.hyperlend_adapter.lend(
|
|
1271
|
+
underlying_token=target_token.get("address"),
|
|
1272
|
+
chain_id=int(self.hype_token_info.get("chain").get("id")),
|
|
1273
|
+
qty=amount_wei,
|
|
1274
|
+
native=False,
|
|
1275
|
+
)
|
|
1276
|
+
if result:
|
|
1277
|
+
self._invalidate_assets_snapshot()
|
|
1278
|
+
amount_lent = amount_wei
|
|
1279
|
+
break
|
|
1280
|
+
except Exception as e:
|
|
1281
|
+
message = str(e)
|
|
1282
|
+
if (
|
|
1283
|
+
"panic code 0x11" in message
|
|
1284
|
+
and amount_wei > 0
|
|
1285
|
+
and attempt < max_attempts - 1
|
|
1286
|
+
):
|
|
1287
|
+
reduction = max(amount_wei // 10, 1)
|
|
1288
|
+
amount_wei -= reduction
|
|
1289
|
+
continue
|
|
1290
|
+
|
|
1291
|
+
return 0.0
|
|
1292
|
+
|
|
1293
|
+
if amount_lent <= 0:
|
|
1294
|
+
return 0.0
|
|
1295
|
+
|
|
1296
|
+
return float(amount_lent) / (10 ** target_token.get("decimals"))
|
|
1297
|
+
|
|
1298
|
+
async def _align_wallet_balances(
|
|
1299
|
+
self, target_token: dict[str, Any], *, hype_reserve_wei: int
|
|
1300
|
+
) -> tuple[list[dict[str, Any]], float]:
|
|
1301
|
+
snapshot = await self._get_assets_snapshot(force_refresh=True)
|
|
1302
|
+
balances = await self._wallet_balances_from_snapshot(snapshot)
|
|
1303
|
+
if not balances:
|
|
1304
|
+
return [], 0.0
|
|
1305
|
+
|
|
1306
|
+
actions = []
|
|
1307
|
+
target_checksum = self._token_checksum(target_token)
|
|
1308
|
+
hype_checksum = self._token_checksum(self.hype_token_info)
|
|
1309
|
+
|
|
1310
|
+
for checksum, entry in balances.items():
|
|
1311
|
+
if checksum == "native":
|
|
1312
|
+
continue
|
|
1313
|
+
if checksum == target_checksum or checksum == hype_checksum:
|
|
1314
|
+
continue
|
|
1315
|
+
asset = entry.get("asset")
|
|
1316
|
+
if not asset or not asset.get("is_stablecoin"):
|
|
1317
|
+
continue
|
|
1318
|
+
token = entry.get("token")
|
|
1319
|
+
if not token or not isinstance(token, dict):
|
|
1320
|
+
continue
|
|
1321
|
+
token["address"] = checksum
|
|
1322
|
+
|
|
1323
|
+
entry_decimals = entry.get("decimals")
|
|
1324
|
+
if entry_decimals is not None:
|
|
1325
|
+
token["decimals"] = entry_decimals
|
|
1326
|
+
balance_wei = int(entry.get("wei") or 0)
|
|
1327
|
+
if balance_wei <= 0:
|
|
1328
|
+
continue
|
|
1329
|
+
|
|
1330
|
+
min_token_swap_wei = self._amount_to_wei(
|
|
1331
|
+
token, Decimal(str(self.MIN_STABLE_SWAP_TOKENS))
|
|
1332
|
+
)
|
|
1333
|
+
if balance_wei <= min_token_swap_wei:
|
|
1334
|
+
continue
|
|
1335
|
+
|
|
1336
|
+
swap_action = await self._execute_swap(
|
|
1337
|
+
from_token_info=token,
|
|
1338
|
+
to_token_info=target_token,
|
|
1339
|
+
amount_wei=balance_wei,
|
|
1340
|
+
slippage=DEFAULT_SLIPPAGE,
|
|
1341
|
+
)
|
|
1342
|
+
if swap_action:
|
|
1343
|
+
actions.append(swap_action)
|
|
1344
|
+
continue
|
|
1345
|
+
|
|
1346
|
+
balance_tokens = float(entry.get("tokens") or 0)
|
|
1347
|
+
if balance_tokens <= 0:
|
|
1348
|
+
continue
|
|
1349
|
+
|
|
1350
|
+
try:
|
|
1351
|
+
(
|
|
1352
|
+
transfer_success,
|
|
1353
|
+
_,
|
|
1354
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
1355
|
+
token_id=token.get("token_id"),
|
|
1356
|
+
amount=balance_tokens,
|
|
1357
|
+
strategy_name=self.name,
|
|
1358
|
+
)
|
|
1359
|
+
except Exception:
|
|
1360
|
+
continue
|
|
1361
|
+
|
|
1362
|
+
if transfer_success:
|
|
1363
|
+
actions.append(
|
|
1364
|
+
f"Transferred {balance_tokens:.4f} {token.get('symbol')} to main wallet"
|
|
1365
|
+
)
|
|
1366
|
+
self._invalidate_assets_snapshot()
|
|
1367
|
+
|
|
1368
|
+
kept_tokens = float(balances.get("native", {}).get("tokens") or 0)
|
|
1369
|
+
|
|
1370
|
+
return actions, kept_tokens
|
|
1371
|
+
|
|
1372
|
+
async def _unwind_other_lends(
|
|
1373
|
+
self, target_token: dict[str, Any]
|
|
1374
|
+
) -> list[dict[str, Any]]:
|
|
1375
|
+
positions = await self._get_lent_positions()
|
|
1376
|
+
if not positions:
|
|
1377
|
+
return []
|
|
1378
|
+
|
|
1379
|
+
actions = []
|
|
1380
|
+
try:
|
|
1381
|
+
target_checksum = self._token_checksum(target_token)
|
|
1382
|
+
except Exception:
|
|
1383
|
+
return actions
|
|
1384
|
+
|
|
1385
|
+
for address, entry in positions.items():
|
|
1386
|
+
token = entry.get("token")
|
|
1387
|
+
amount_wei = entry.get("amount_wei", 0)
|
|
1388
|
+
if not token or amount_wei <= 0:
|
|
1389
|
+
continue
|
|
1390
|
+
|
|
1391
|
+
try:
|
|
1392
|
+
checksum = Web3.to_checksum_address(address)
|
|
1393
|
+
except Exception:
|
|
1394
|
+
continue
|
|
1395
|
+
if checksum == target_checksum:
|
|
1396
|
+
continue
|
|
1397
|
+
try:
|
|
1398
|
+
chain_code = self.hype_token_info.get("chain", {}).get(
|
|
1399
|
+
"code", "hyperevm"
|
|
1400
|
+
)
|
|
1401
|
+
underlying_token_address = self._get_token_address(token, chain_code)
|
|
1402
|
+
if not underlying_token_address:
|
|
1403
|
+
continue
|
|
1404
|
+
# TODO: await favourable fees
|
|
1405
|
+
await self.hyperlend_adapter.unlend(
|
|
1406
|
+
underlying_token=underlying_token_address,
|
|
1407
|
+
qty=int(amount_wei),
|
|
1408
|
+
chain_id=int(self.hype_token_info.get("chain").get("id")),
|
|
1409
|
+
native=False,
|
|
1410
|
+
)
|
|
1411
|
+
self._invalidate_assets_snapshot()
|
|
1412
|
+
human = float(amount_wei) / (10 ** token.get("decimals"))
|
|
1413
|
+
actions.append(
|
|
1414
|
+
f"Unwound {human:.4f} {token.get('symbol')} from HyperLend"
|
|
1415
|
+
)
|
|
1416
|
+
except Exception:
|
|
1417
|
+
continue
|
|
1418
|
+
|
|
1419
|
+
return actions
|
|
1420
|
+
|
|
1421
|
+
async def _get_last_rotation_time(self, wallet_address: str) -> datetime | None:
|
|
1422
|
+
success, data = await self.ledger_adapter.get_strategy_latest_transactions(
|
|
1423
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
1424
|
+
)
|
|
1425
|
+
if success is False:
|
|
1426
|
+
return None
|
|
1427
|
+
for transaction in data.get("transactions", []):
|
|
1428
|
+
op_data = transaction.get("op_data", {})
|
|
1429
|
+
if op_data.get("type") in {"LEND", "SWAP"}:
|
|
1430
|
+
created_str = transaction.get("created")
|
|
1431
|
+
if not created_str:
|
|
1432
|
+
continue
|
|
1433
|
+
try:
|
|
1434
|
+
dt = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
|
|
1435
|
+
if dt.tzinfo is None:
|
|
1436
|
+
dt = dt.replace(tzinfo=UTC)
|
|
1437
|
+
return dt
|
|
1438
|
+
except (ValueError, AttributeError):
|
|
1439
|
+
continue
|
|
1440
|
+
return None
|
|
1441
|
+
|
|
1442
|
+
async def _select_best_stable_asset(
|
|
1443
|
+
self,
|
|
1444
|
+
lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
|
|
1445
|
+
*,
|
|
1446
|
+
required_underlying_tokens=None,
|
|
1447
|
+
operation: Literal["deposit", "update", "quote"] = "update",
|
|
1448
|
+
exclude_addresses=None,
|
|
1449
|
+
allow_rotation_without_current=False,
|
|
1450
|
+
) -> dict[str, Any] | None:
|
|
1451
|
+
excluded = (
|
|
1452
|
+
{addr.lower() for addr in exclude_addresses}
|
|
1453
|
+
if exclude_addresses
|
|
1454
|
+
else set[Any]()
|
|
1455
|
+
)
|
|
1456
|
+
current_token = self.current_token
|
|
1457
|
+
current_symbol = self.current_symbol
|
|
1458
|
+
|
|
1459
|
+
current_checksum_value = (
|
|
1460
|
+
self._token_checksum(current_token) if current_token else None
|
|
1461
|
+
)
|
|
1462
|
+
current_checksum_lower = (
|
|
1463
|
+
current_checksum_value.lower() if current_checksum_value else None
|
|
1464
|
+
)
|
|
1465
|
+
current_excluded = current_checksum_lower and current_checksum_lower in excluded
|
|
1466
|
+
allow_current_fallback = (
|
|
1467
|
+
operation != "deposit"
|
|
1468
|
+
and current_token is not None
|
|
1469
|
+
and not current_excluded
|
|
1470
|
+
)
|
|
1471
|
+
|
|
1472
|
+
_, stable_markets = await self.hyperlend_adapter.get_stable_markets(
|
|
1473
|
+
chain_id=self.hype_token_info.get("chain").get("id"),
|
|
1474
|
+
required_underlying_tokens=required_underlying_tokens,
|
|
1475
|
+
buffer_bps=self.SUPPLY_CAP_BUFFER_BPS,
|
|
1476
|
+
min_buffer_tokens=self.SUPPLY_CAP_MIN_BUFFER_TOKENS,
|
|
1477
|
+
is_stable_symbol=True,
|
|
1478
|
+
)
|
|
1479
|
+
filtered_notes = stable_markets.get("notes", [])
|
|
1480
|
+
filtered_map = stable_markets.get("markets", {})
|
|
1481
|
+
|
|
1482
|
+
if excluded:
|
|
1483
|
+
pruned = {}
|
|
1484
|
+
for addr, entry in filtered_map.items():
|
|
1485
|
+
try:
|
|
1486
|
+
checksum = Web3.to_checksum_address(addr)
|
|
1487
|
+
except Exception:
|
|
1488
|
+
checksum = addr
|
|
1489
|
+
if str(checksum).lower() in excluded:
|
|
1490
|
+
continue
|
|
1491
|
+
pruned[addr] = entry
|
|
1492
|
+
filtered_map = pruned
|
|
1493
|
+
|
|
1494
|
+
if (
|
|
1495
|
+
allow_rotation_without_current
|
|
1496
|
+
and current_token
|
|
1497
|
+
and current_checksum_lower
|
|
1498
|
+
and not current_excluded
|
|
1499
|
+
):
|
|
1500
|
+
existing_addresses = set[str]()
|
|
1501
|
+
for addr in filtered_map.keys():
|
|
1502
|
+
try:
|
|
1503
|
+
existing_addresses.add(Web3.to_checksum_address(addr).lower())
|
|
1504
|
+
except Exception:
|
|
1505
|
+
existing_addresses.add(str(addr).lower())
|
|
1506
|
+
if current_checksum_lower not in existing_addresses:
|
|
1507
|
+
try:
|
|
1508
|
+
_, current_entry = await self.hyperlend_adapter.get_market_entry(
|
|
1509
|
+
chain_id=self.hype_token_info.get("chain").get("id"),
|
|
1510
|
+
token_address=current_checksum_value,
|
|
1511
|
+
)
|
|
1512
|
+
except Exception:
|
|
1513
|
+
current_entry = None
|
|
1514
|
+
if current_entry:
|
|
1515
|
+
filtered_map[current_token.get("address")] = current_entry
|
|
1516
|
+
filtered_notes.append(
|
|
1517
|
+
f"Included capped market {current_symbol or current_token.get('symbol') or 'unknown'} for rotation comparison."
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
if not filtered_map:
|
|
1521
|
+
if filtered_notes:
|
|
1522
|
+
truncated = "; ".join(filtered_notes[:3])
|
|
1523
|
+
if len(filtered_notes) > 3:
|
|
1524
|
+
truncated += f"{truncated} ..."
|
|
1525
|
+
if allow_current_fallback and current_token:
|
|
1526
|
+
fallback_symbol = current_symbol or current_token.get("symbol")
|
|
1527
|
+
fallback_hourly = self._apy_to_hourly(
|
|
1528
|
+
float(self.current_avg_apy or 0.0)
|
|
1529
|
+
)
|
|
1530
|
+
if not current_token.get("address"):
|
|
1531
|
+
if not current_checksum_value:
|
|
1532
|
+
return None
|
|
1533
|
+
current_token["address"] = current_checksum_value
|
|
1534
|
+
return (current_token, fallback_symbol, fallback_hourly)
|
|
1535
|
+
return None
|
|
1536
|
+
|
|
1537
|
+
self.symbol_display_map = {}
|
|
1538
|
+
filtered = []
|
|
1539
|
+
for addr, entry in filtered_map.items():
|
|
1540
|
+
symbol_canonical = entry.get("symbol_canonical")
|
|
1541
|
+
if not symbol_canonical:
|
|
1542
|
+
raw_symbol = entry.get("symbol") or entry.get("display_symbol")
|
|
1543
|
+
symbol_canonical = (
|
|
1544
|
+
self._normalize_symbol(raw_symbol) if raw_symbol else None
|
|
1545
|
+
)
|
|
1546
|
+
if not symbol_canonical:
|
|
1547
|
+
continue
|
|
1548
|
+
display_symbol = (
|
|
1549
|
+
entry.get("display_symbol") or entry.get("symbol") or symbol_canonical
|
|
1550
|
+
)
|
|
1551
|
+
self.symbol_display_map[symbol_canonical] = str(display_symbol)
|
|
1552
|
+
filtered.append((addr, symbol_canonical))
|
|
1553
|
+
|
|
1554
|
+
histories = await asyncio.gather(
|
|
1555
|
+
*[
|
|
1556
|
+
self.hyperlend_adapter.get_lend_rate_history(
|
|
1557
|
+
chain_id=self.hype_token_info.get("chain").get("id"),
|
|
1558
|
+
token_address=addr,
|
|
1559
|
+
lookback_hours=lookback_hours,
|
|
1560
|
+
)
|
|
1561
|
+
for addr, _ in filtered
|
|
1562
|
+
],
|
|
1563
|
+
return_exceptions=True,
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
records = []
|
|
1567
|
+
symbol_map = {}
|
|
1568
|
+
for (addr, symbol), history in zip(filtered, histories, strict=False):
|
|
1569
|
+
label = symbol or addr
|
|
1570
|
+
symbol_map[label] = addr
|
|
1571
|
+
if isinstance(history, Exception):
|
|
1572
|
+
self.logger.warning(
|
|
1573
|
+
f"Exception fetching rate history for {label} ({addr}): {history}"
|
|
1574
|
+
)
|
|
1575
|
+
continue
|
|
1576
|
+
history_status = history[0]
|
|
1577
|
+
if not history_status:
|
|
1578
|
+
continue
|
|
1579
|
+
history_data = history[1]
|
|
1580
|
+
for row in history_data.get("rate_history", []):
|
|
1581
|
+
ts_ms = row.get("timestamp_ms")
|
|
1582
|
+
if ts_ms is None:
|
|
1583
|
+
continue
|
|
1584
|
+
apr = row.get("supply_apr")
|
|
1585
|
+
apy = row.get("supply_apy")
|
|
1586
|
+
rate_hourly = None
|
|
1587
|
+
if isinstance(apr, (int, float)) and not math.isnan(apr):
|
|
1588
|
+
rate_hourly = np.expm1(np.log1p(apr) / (365 * 24))
|
|
1589
|
+
elif isinstance(apy, (int, float)) and not math.isnan(apy):
|
|
1590
|
+
rate_hourly = (1.0 + apy) ** (1 / (365 * 24)) - 1.0
|
|
1591
|
+
records.append(
|
|
1592
|
+
{
|
|
1593
|
+
"timestamp": pd.to_datetime(ts_ms, unit="ms", utc=True),
|
|
1594
|
+
"asset": label,
|
|
1595
|
+
"supplyAPR": apr,
|
|
1596
|
+
"rate_hourly": rate_hourly,
|
|
1597
|
+
}
|
|
1598
|
+
)
|
|
1599
|
+
if not records:
|
|
1600
|
+
self.last_summary = None
|
|
1601
|
+
self.last_dominance = None
|
|
1602
|
+
self.last_samples = None
|
|
1603
|
+
return None
|
|
1604
|
+
|
|
1605
|
+
rates_df = pd.DataFrame(records)
|
|
1606
|
+
try:
|
|
1607
|
+
wide = self._prep_rates(rates_df)
|
|
1608
|
+
except Exception as e:
|
|
1609
|
+
self.logger.error(f"Error preparing rates: {e}")
|
|
1610
|
+
self.last_summary = None
|
|
1611
|
+
self.last_dominance = None
|
|
1612
|
+
self.last_samples = None
|
|
1613
|
+
return None
|
|
1614
|
+
|
|
1615
|
+
if wide.empty or wide.shape[1] == 0:
|
|
1616
|
+
self.last_summary = None
|
|
1617
|
+
self.last_dominance = None
|
|
1618
|
+
self.last_samples = None
|
|
1619
|
+
return None
|
|
1620
|
+
|
|
1621
|
+
if self.TOURNAMENT_MODE == "joint":
|
|
1622
|
+
summary, dominance, samples = self._tournament(
|
|
1623
|
+
wide,
|
|
1624
|
+
horizon_h=self.HORIZON_HOURS,
|
|
1625
|
+
block_len=self.BLOCK_LEN,
|
|
1626
|
+
trials=self.TRIALS,
|
|
1627
|
+
halflife_days=self.HALFLIFE_DAYS,
|
|
1628
|
+
seed=self.SEED,
|
|
1629
|
+
)
|
|
1630
|
+
else:
|
|
1631
|
+
summary, dominance, samples = self._tournament_independent(
|
|
1632
|
+
wide,
|
|
1633
|
+
horizon_h=self.HORIZON_HOURS,
|
|
1634
|
+
block_len=self.BLOCK_LEN,
|
|
1635
|
+
trials=self.TRIALS,
|
|
1636
|
+
halflife_days=self.HALFLIFE_DAYS,
|
|
1637
|
+
seed=self.SEED,
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
self.last_summary = summary
|
|
1641
|
+
self.last_dominance = dominance
|
|
1642
|
+
self.last_samples = samples
|
|
1643
|
+
|
|
1644
|
+
if summary.empty:
|
|
1645
|
+
if allow_current_fallback and current_token:
|
|
1646
|
+
fallback_hourly = self._apy_to_hourly(
|
|
1647
|
+
float(self.current_avg_apy or 0.0)
|
|
1648
|
+
)
|
|
1649
|
+
fallback_symbol = current_symbol or current_token.get("symbol")
|
|
1650
|
+
fallback_address = (
|
|
1651
|
+
current_token.get("address") or current_checksum_value
|
|
1652
|
+
)
|
|
1653
|
+
if not fallback_address:
|
|
1654
|
+
return None
|
|
1655
|
+
if not current_token.get("address"):
|
|
1656
|
+
current_token["address"] = fallback_address
|
|
1657
|
+
return (current_token, fallback_symbol, fallback_hourly)
|
|
1658
|
+
return None
|
|
1659
|
+
|
|
1660
|
+
max_candidates = min(self.MAX_CANDIDATES, len(summary))
|
|
1661
|
+
for i in range(max_candidates):
|
|
1662
|
+
top_row = summary.iloc[i]
|
|
1663
|
+
top_symbol = top_row["asset"]
|
|
1664
|
+
|
|
1665
|
+
current_candidate = None
|
|
1666
|
+
if current_symbol:
|
|
1667
|
+
current_row_df = summary.loc[summary["asset"] == current_symbol]
|
|
1668
|
+
if not current_row_df.empty:
|
|
1669
|
+
current_candidate = await self._make_candidate(
|
|
1670
|
+
current_symbol,
|
|
1671
|
+
current_row_df.iloc[0],
|
|
1672
|
+
symbol_map,
|
|
1673
|
+
current_token,
|
|
1674
|
+
current_symbol,
|
|
1675
|
+
)
|
|
1676
|
+
elif allow_current_fallback and current_token:
|
|
1677
|
+
hourly = self._apy_to_hourly(float(self.current_avg_apy or 0.0))
|
|
1678
|
+
if not current_token.get("address"):
|
|
1679
|
+
if not current_checksum_value:
|
|
1680
|
+
current_candidate = None
|
|
1681
|
+
else:
|
|
1682
|
+
current_token["address"] = current_checksum_value
|
|
1683
|
+
current_candidate = (current_token, current_symbol, hourly)
|
|
1684
|
+
else:
|
|
1685
|
+
current_candidate = (current_token, current_symbol, hourly)
|
|
1686
|
+
|
|
1687
|
+
can_rotate = True
|
|
1688
|
+
if (
|
|
1689
|
+
required_underlying_tokens is not None
|
|
1690
|
+
and required_underlying_tokens > 0
|
|
1691
|
+
and current_symbol
|
|
1692
|
+
and current_candidate is not None
|
|
1693
|
+
):
|
|
1694
|
+
current_row_df = summary.loc[summary["asset"] == current_symbol]
|
|
1695
|
+
if current_row_df.empty:
|
|
1696
|
+
if allow_current_fallback and (
|
|
1697
|
+
current_excluded or allow_rotation_without_current
|
|
1698
|
+
):
|
|
1699
|
+
current_p = 0.0
|
|
1700
|
+
else:
|
|
1701
|
+
can_rotate = False
|
|
1702
|
+
current_p = 0.0
|
|
1703
|
+
else:
|
|
1704
|
+
current_p = float(current_row_df.iloc[0].get("p_best", 0.0) or 0.0)
|
|
1705
|
+
if can_rotate:
|
|
1706
|
+
top_p = float(top_row.get("p_best", 0.0) or 0.0)
|
|
1707
|
+
if current_p > 0:
|
|
1708
|
+
can_rotate = top_p > max(
|
|
1709
|
+
current_p, self.P_BEST_ROTATION_THRESHOLD
|
|
1710
|
+
)
|
|
1711
|
+
else:
|
|
1712
|
+
can_rotate = top_p > self.P_BEST_ROTATION_THRESHOLD
|
|
1713
|
+
if not can_rotate:
|
|
1714
|
+
return current_candidate
|
|
1715
|
+
|
|
1716
|
+
candidate = await self._make_candidate(
|
|
1717
|
+
top_symbol, top_row, symbol_map, current_token, current_symbol
|
|
1718
|
+
)
|
|
1719
|
+
|
|
1720
|
+
if candidate:
|
|
1721
|
+
return candidate
|
|
1722
|
+
return current_candidate
|
|
1723
|
+
|
|
1724
|
+
async def _make_candidate(
|
|
1725
|
+
self,
|
|
1726
|
+
symbol: str,
|
|
1727
|
+
row: pd.Series,
|
|
1728
|
+
symbol_map: dict[str, str],
|
|
1729
|
+
current_token: dict[str, Any] | None = None,
|
|
1730
|
+
current_symbol: str | None = None,
|
|
1731
|
+
):
|
|
1732
|
+
address = symbol_map.get(symbol)
|
|
1733
|
+
token = None
|
|
1734
|
+
if address:
|
|
1735
|
+
try:
|
|
1736
|
+
success, token = await self.token_adapter.get_token(address.lower())
|
|
1737
|
+
except Exception:
|
|
1738
|
+
token = None
|
|
1739
|
+
if not success:
|
|
1740
|
+
token = None
|
|
1741
|
+
if token is None and current_token is None and symbol == current_symbol:
|
|
1742
|
+
token = current_token
|
|
1743
|
+
if not token:
|
|
1744
|
+
return None
|
|
1745
|
+
if not address:
|
|
1746
|
+
address = token.get("address") if token else None
|
|
1747
|
+
if not address:
|
|
1748
|
+
return None
|
|
1749
|
+
token["address"] = address
|
|
1750
|
+
hourly_rate = self._log_yield_to_hourly(float(row.get("mean", 0.0) or 0.0))
|
|
1751
|
+
return (token, symbol, hourly_rate)
|
|
1752
|
+
|
|
1753
|
+
def _prep_rates(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
1754
|
+
df = self._coerce_rates_df(df).copy()
|
|
1755
|
+
df["ts"] = pd.to_datetime(df["timestamp"]).dt.floor("h")
|
|
1756
|
+
wide = (
|
|
1757
|
+
df.pivot_table(
|
|
1758
|
+
index="ts", columns="asset", values="rate_hourly", aggfunc="mean"
|
|
1759
|
+
)
|
|
1760
|
+
.sort_index()
|
|
1761
|
+
.dropna(axis=0, how="any")
|
|
1762
|
+
)
|
|
1763
|
+
return wide
|
|
1764
|
+
|
|
1765
|
+
def _coerce_rates_df(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
1766
|
+
g = df.copy()
|
|
1767
|
+
if "timestamp" not in g.columns:
|
|
1768
|
+
if "ts" in g.columns:
|
|
1769
|
+
g = g.rename(columns={"ts": "timestamp"})
|
|
1770
|
+
else:
|
|
1771
|
+
raise KeyError("Expected a 'timestamp' column.")
|
|
1772
|
+
if "asset" not in g.columns:
|
|
1773
|
+
if "symbol" in g.columns:
|
|
1774
|
+
g = g.rename(columns={"symbol": "asset"})
|
|
1775
|
+
else:
|
|
1776
|
+
raise KeyError("Expected an 'asset' (or 'symbol') column.")
|
|
1777
|
+
if "rate_hourly" not in g.columns:
|
|
1778
|
+
if "supplyAPR" in g.columns:
|
|
1779
|
+
g["rate_hourly"] = np.expm1(np.log1p(g["supplyAPR"]) / (365 * 24))
|
|
1780
|
+
elif "supplyAPY" in g.columns:
|
|
1781
|
+
g["rate_hourly"] = (1.0 + g["supplyAPY"]) ** (1 / (365 * 24)) - 1.0
|
|
1782
|
+
else:
|
|
1783
|
+
raise KeyError(
|
|
1784
|
+
"Need 'rate_hourly' or one of 'supplyAPR'/'supplyAPY' to derive it."
|
|
1785
|
+
)
|
|
1786
|
+
g["timestamp"] = pd.to_datetime(g["timestamp"], utc=True)
|
|
1787
|
+
return g
|
|
1788
|
+
|
|
1789
|
+
def _tournament(
|
|
1790
|
+
self,
|
|
1791
|
+
wide: pd.DataFrame,
|
|
1792
|
+
horizon_h: int = None,
|
|
1793
|
+
block_len: int | None = None,
|
|
1794
|
+
trials: int | None = None,
|
|
1795
|
+
halflife_days: float | None = None,
|
|
1796
|
+
seed: int | None = None,
|
|
1797
|
+
):
|
|
1798
|
+
if horizon_h is None:
|
|
1799
|
+
horizon_h = self.HORIZON_HOURS
|
|
1800
|
+
if block_len is None:
|
|
1801
|
+
block_len = self.BLOCK_LEN
|
|
1802
|
+
if trials is None:
|
|
1803
|
+
trials = self.TRIALS
|
|
1804
|
+
if halflife_days is None:
|
|
1805
|
+
halflife_days = self.HALFLIFE_DAYS
|
|
1806
|
+
if seed is None:
|
|
1807
|
+
seed = self.SEED
|
|
1808
|
+
|
|
1809
|
+
wide = wide.copy()
|
|
1810
|
+
assets = wide.columns.to_list()
|
|
1811
|
+
arr = wide.values
|
|
1812
|
+
|
|
1813
|
+
w = self.recency_weights(wide.index, halflife_days=halflife_days)
|
|
1814
|
+
rng = np.random.default_rng(seed=seed)
|
|
1815
|
+
A = arr.shape[1]
|
|
1816
|
+
|
|
1817
|
+
wins = np.zeros(A, dtype=int)
|
|
1818
|
+
all_trial_log_returns = np.empty((trials, A))
|
|
1819
|
+
for t in range(trials):
|
|
1820
|
+
horizon_log_returns = self.sample_sequence_block_bootstrap(
|
|
1821
|
+
arr, horizon_h, block_len, start_weights=w, rng=rng
|
|
1822
|
+
)
|
|
1823
|
+
all_trial_log_returns[t] = horizon_log_returns
|
|
1824
|
+
wins[np.argmax(horizon_log_returns)] += 1
|
|
1825
|
+
|
|
1826
|
+
p_best = wins / trials
|
|
1827
|
+
q05 = np.quantile(all_trial_log_returns, 0.05, axis=0)
|
|
1828
|
+
mean = all_trial_log_returns.mean(axis=0)
|
|
1829
|
+
std = all_trial_log_returns.std(axis=0)
|
|
1830
|
+
|
|
1831
|
+
dom = np.zeros((A, A))
|
|
1832
|
+
for i in range(A):
|
|
1833
|
+
for j in range(A):
|
|
1834
|
+
if i == j:
|
|
1835
|
+
continue
|
|
1836
|
+
dom[i, j] = np.mean(
|
|
1837
|
+
all_trial_log_returns[:, i] > all_trial_log_returns[:, j]
|
|
1838
|
+
)
|
|
1839
|
+
|
|
1840
|
+
summary = pd.DataFrame(
|
|
1841
|
+
{
|
|
1842
|
+
"asset": assets,
|
|
1843
|
+
"p_best": p_best,
|
|
1844
|
+
"q05": q05,
|
|
1845
|
+
"mean": mean,
|
|
1846
|
+
"std": std,
|
|
1847
|
+
}
|
|
1848
|
+
).sort_values(
|
|
1849
|
+
["p_best", "q05", "mean"],
|
|
1850
|
+
ascending=[False, False, False],
|
|
1851
|
+
)
|
|
1852
|
+
|
|
1853
|
+
dominance = pd.DataFrame(dom, index=assets, columns=assets)
|
|
1854
|
+
return summary, dominance, all_trial_log_returns
|
|
1855
|
+
|
|
1856
|
+
def _tournament_independent(
|
|
1857
|
+
self,
|
|
1858
|
+
wide: pd.DataFrame,
|
|
1859
|
+
horizon_h: int = None,
|
|
1860
|
+
block_len: int | None = None,
|
|
1861
|
+
trials: int | None = None,
|
|
1862
|
+
halflife_days: float | None = None,
|
|
1863
|
+
seed: int | None = None,
|
|
1864
|
+
):
|
|
1865
|
+
if horizon_h is None:
|
|
1866
|
+
horizon_h = self.HORIZON_HOURS
|
|
1867
|
+
if block_len is None:
|
|
1868
|
+
block_len = self.BLOCK_LEN
|
|
1869
|
+
if trials is None:
|
|
1870
|
+
trials = self.TRIALS
|
|
1871
|
+
if halflife_days is None:
|
|
1872
|
+
halflife_days = self.HALFLIFE_DAYS
|
|
1873
|
+
if seed is None:
|
|
1874
|
+
seed = self.SEED
|
|
1875
|
+
|
|
1876
|
+
wide = wide.copy()
|
|
1877
|
+
assets = wide.columns.to_list()
|
|
1878
|
+
arr = wide.values
|
|
1879
|
+
w = self.recency_weights(wide.index, halflife_days=halflife_days)
|
|
1880
|
+
rng = np.random.default_rng(seed=seed)
|
|
1881
|
+
A = arr.shape[1]
|
|
1882
|
+
|
|
1883
|
+
wins = np.zeros(A, dtype=int)
|
|
1884
|
+
all_trial_log_returns = np.empty((trials, A), dtype=float)
|
|
1885
|
+
|
|
1886
|
+
for t in range(trials):
|
|
1887
|
+
for i in range(A):
|
|
1888
|
+
all_trial_log_returns[t, i] = self.sample_seq_independent(
|
|
1889
|
+
arr[:, i], horizon_h, block_len, start_weights=w, rng=rng
|
|
1890
|
+
)
|
|
1891
|
+
wins[np.argmax(all_trial_log_returns[t])] += 1
|
|
1892
|
+
|
|
1893
|
+
p_best = wins / trials
|
|
1894
|
+
q05 = np.quantile(all_trial_log_returns, 0.05, axis=0)
|
|
1895
|
+
mean = all_trial_log_returns.mean(axis=0)
|
|
1896
|
+
std = all_trial_log_returns.std(axis=0)
|
|
1897
|
+
|
|
1898
|
+
dom = np.zeros((A, A), dtype=float)
|
|
1899
|
+
for i in range(A):
|
|
1900
|
+
for j in range(A):
|
|
1901
|
+
if i == j:
|
|
1902
|
+
continue
|
|
1903
|
+
dom[i, j] = float(
|
|
1904
|
+
np.mean(all_trial_log_returns[:, i] > all_trial_log_returns[:, j])
|
|
1905
|
+
)
|
|
1906
|
+
|
|
1907
|
+
summary = pd.DataFrame(
|
|
1908
|
+
{
|
|
1909
|
+
"asset": assets,
|
|
1910
|
+
"p_best": p_best,
|
|
1911
|
+
"q05": q05,
|
|
1912
|
+
"mean": mean,
|
|
1913
|
+
"std": std,
|
|
1914
|
+
}
|
|
1915
|
+
).sort_values(
|
|
1916
|
+
["p_best", "q05", "mean"],
|
|
1917
|
+
ascending=[False, False, False],
|
|
1918
|
+
)
|
|
1919
|
+
|
|
1920
|
+
dominance = pd.DataFrame(dom, index=assets, columns=assets)
|
|
1921
|
+
return summary, dominance, all_trial_log_returns
|
|
1922
|
+
|
|
1923
|
+
@staticmethod
|
|
1924
|
+
def sample_sequence_block_bootstrap(
|
|
1925
|
+
arr: np.ndarray,
|
|
1926
|
+
horizon_h: int = HORIZON_HOURS,
|
|
1927
|
+
block_len: int = BLOCK_LEN,
|
|
1928
|
+
start_weights: np.ndarray | None = None,
|
|
1929
|
+
rng: np.random.Generator | None = None,
|
|
1930
|
+
) -> np.ndarray:
|
|
1931
|
+
if rng is None:
|
|
1932
|
+
rng = np.random.default_rng()
|
|
1933
|
+
|
|
1934
|
+
T, _ = arr.shape
|
|
1935
|
+
max_start = T - block_len
|
|
1936
|
+
if max_start < 0:
|
|
1937
|
+
raise ValueError("Not enough rows for chosen block length.")
|
|
1938
|
+
|
|
1939
|
+
starts = np.arange(max_start + 1)
|
|
1940
|
+
if start_weights is None:
|
|
1941
|
+
probs = np.ones_like(starts, dtype=float) / (max_start + 1)
|
|
1942
|
+
else:
|
|
1943
|
+
probs = start_weights[: max_start + 1].astype(float)
|
|
1944
|
+
probs /= probs.sum()
|
|
1945
|
+
|
|
1946
|
+
picked = []
|
|
1947
|
+
need = horizon_h
|
|
1948
|
+
while need > 0:
|
|
1949
|
+
s = rng.choice(starts, p=probs)
|
|
1950
|
+
picked.append(arr[s : s + block_len])
|
|
1951
|
+
need -= block_len
|
|
1952
|
+
|
|
1953
|
+
seq = np.vstack(picked)[:horizon_h]
|
|
1954
|
+
return np.sum(np.log1p(seq), axis=0)
|
|
1955
|
+
|
|
1956
|
+
@staticmethod
|
|
1957
|
+
def sample_seq_independent(
|
|
1958
|
+
col: np.ndarray,
|
|
1959
|
+
horizon_h: int = HORIZON_HOURS,
|
|
1960
|
+
block_len: int = BLOCK_LEN,
|
|
1961
|
+
start_weights: np.ndarray | None = None,
|
|
1962
|
+
rng: np.random.Generator | None = None,
|
|
1963
|
+
) -> np.ndarray:
|
|
1964
|
+
T = len(col)
|
|
1965
|
+
max_start = T - block_len
|
|
1966
|
+
if max_start < 0:
|
|
1967
|
+
raise ValueError("Not enough rows for chosen BLOCK_LEN.")
|
|
1968
|
+
|
|
1969
|
+
starts = np.arange(max_start + 1)
|
|
1970
|
+
probs = start_weights[: max_start + 1].astype(float)
|
|
1971
|
+
probs /= probs.sum()
|
|
1972
|
+
|
|
1973
|
+
picked: list[np.ndarray] = []
|
|
1974
|
+
need = horizon_h
|
|
1975
|
+
while need > 0:
|
|
1976
|
+
s = rng.choice(starts, p=probs)
|
|
1977
|
+
picked.append(col[s : s + block_len])
|
|
1978
|
+
need -= block_len
|
|
1979
|
+
seq = np.concatenate(picked)[:horizon_h]
|
|
1980
|
+
return float(np.sum(np.log1p(seq)))
|
|
1981
|
+
|
|
1982
|
+
def recency_weights(self, index: pd.DatetimeIndex, halflife_days) -> np.ndarray:
|
|
1983
|
+
ages_h = ((index.max() - index) / pd.Timedelta(hours=1)).to_numpy()
|
|
1984
|
+
lam = math.log(2) / (halflife_days * 24.0)
|
|
1985
|
+
w = np.exp(-lam * ages_h)
|
|
1986
|
+
total = w.sum()
|
|
1987
|
+
|
|
1988
|
+
if total == 0:
|
|
1989
|
+
return np.ones_like(w) / len(w)
|
|
1990
|
+
return w / total
|
|
1991
|
+
|
|
1992
|
+
@staticmethod
|
|
1993
|
+
def _apy_to_hourly(apy: float) -> float:
|
|
1994
|
+
return (1.0 + apy) ** (1 / (365 * 24)) - 1 if apy is not None else 0.0
|
|
1995
|
+
|
|
1996
|
+
@staticmethod
|
|
1997
|
+
def _hourly_to_apy(rate_hourly: float) -> float:
|
|
1998
|
+
return (1.0 + rate_hourly) ** (365 * 24) - 1.0
|
|
1999
|
+
|
|
2000
|
+
@staticmethod
|
|
2001
|
+
def _log_yield_to_hourly(log_yield: float, horizon_h: int = 6) -> float:
|
|
2002
|
+
return float(np.expm1(log_yield / max(horizon_h, 1)))
|
|
2003
|
+
|
|
2004
|
+
async def _get_idle_tokens(self) -> float:
|
|
2005
|
+
snapshot = await self._get_assets_snapshot()
|
|
2006
|
+
balances = await self._wallet_balances_from_snapshot(snapshot)
|
|
2007
|
+
if not balances:
|
|
2008
|
+
return 0.0
|
|
2009
|
+
|
|
2010
|
+
idle_total = 0.0
|
|
2011
|
+
token = self.current_token
|
|
2012
|
+
current_checksum = None
|
|
2013
|
+
if token and token.get("address"):
|
|
2014
|
+
current_checksum = self._token_checksum(token)
|
|
2015
|
+
|
|
2016
|
+
for checksum, entry in balances.items():
|
|
2017
|
+
if checksum == "native":
|
|
2018
|
+
continue
|
|
2019
|
+
include_balance = False
|
|
2020
|
+
if current_checksum and checksum == current_checksum:
|
|
2021
|
+
include_balance = True
|
|
2022
|
+
else:
|
|
2023
|
+
asset = entry.get("asset") or {}
|
|
2024
|
+
symbol = (
|
|
2025
|
+
(asset or {}).get("symbol")
|
|
2026
|
+
or (asset or {}).get("symbol_display")
|
|
2027
|
+
or ""
|
|
2028
|
+
)
|
|
2029
|
+
if self._is_stable_symbol(symbol):
|
|
2030
|
+
include_balance = True
|
|
2031
|
+
if not include_balance:
|
|
2032
|
+
continue
|
|
2033
|
+
idle_total += float(entry.get("tokens") or 0.0)
|
|
2034
|
+
return idle_total
|
|
2035
|
+
|
|
2036
|
+
async def _wallet_balances_from_snapshot(
|
|
2037
|
+
self, snapshot: dict[str, Any]
|
|
2038
|
+
) -> dict[str, Any]:
|
|
2039
|
+
balances = {}
|
|
2040
|
+
assets = snapshot.get("assets_view", {}).get("assets", [])
|
|
2041
|
+
if assets:
|
|
2042
|
+
for asset in assets:
|
|
2043
|
+
checksum = asset.get("underlying_checksum") or asset.get("underlying")
|
|
2044
|
+
if not checksum:
|
|
2045
|
+
continue
|
|
2046
|
+
try:
|
|
2047
|
+
checksum = Web3.to_checksum_address(checksum)
|
|
2048
|
+
except Exception:
|
|
2049
|
+
continue
|
|
2050
|
+
|
|
2051
|
+
try:
|
|
2052
|
+
success, token = await self.token_adapter.get_token(checksum)
|
|
2053
|
+
if not success or not isinstance(token, dict):
|
|
2054
|
+
continue
|
|
2055
|
+
except Exception:
|
|
2056
|
+
continue
|
|
2057
|
+
|
|
2058
|
+
raw_balance_wei = asset.get("underlying_wallet_balance_wei")
|
|
2059
|
+
try:
|
|
2060
|
+
# Handle both "decimals" (plural) and "decimal" (singular) from API
|
|
2061
|
+
token_decimals = token.get("decimals") or token.get("decimal")
|
|
2062
|
+
asset_decimals = asset.get("decimals") or asset.get("decimal")
|
|
2063
|
+
if token_decimals is not None:
|
|
2064
|
+
decimals = int(token_decimals)
|
|
2065
|
+
elif asset_decimals is not None:
|
|
2066
|
+
decimals = int(asset_decimals)
|
|
2067
|
+
else:
|
|
2068
|
+
decimals = 18
|
|
2069
|
+
except (TypeError, ValueError):
|
|
2070
|
+
decimals = 18
|
|
2071
|
+
scale = 10**decimals
|
|
2072
|
+
if raw_balance_wei is not None:
|
|
2073
|
+
try:
|
|
2074
|
+
balance_wei = int(raw_balance_wei)
|
|
2075
|
+
except (TypeError, ValueError):
|
|
2076
|
+
balance_wei = None
|
|
2077
|
+
else:
|
|
2078
|
+
balance_wei = None
|
|
2079
|
+
if balance_wei is None:
|
|
2080
|
+
balance_decimal_input = Decimal(
|
|
2081
|
+
str(asset.get("underlying_wallet_balance") or 0.0)
|
|
2082
|
+
)
|
|
2083
|
+
balance_wei = int(balance_decimal_input * scale).to_integral_value(
|
|
2084
|
+
rounding=ROUND_DOWN
|
|
2085
|
+
)
|
|
2086
|
+
if balance_wei < 0:
|
|
2087
|
+
balance_wei = 0
|
|
2088
|
+
balance_decimal = (
|
|
2089
|
+
Decimal(balance_wei) / scale if balance_wei else Decimal(0.0)
|
|
2090
|
+
)
|
|
2091
|
+
balance_tokens = float(balance_decimal) if balance_decimal else 0.0
|
|
2092
|
+
if balance_tokens > 0.0:
|
|
2093
|
+
float_decimal = Decimal.from_float(balance_tokens)
|
|
2094
|
+
if float_decimal > balance_decimal:
|
|
2095
|
+
balance_tokens = math.nextafter(balance_tokens, 0.0)
|
|
2096
|
+
while (
|
|
2097
|
+
balance_tokens > 0.0
|
|
2098
|
+
and Decimal.from_float(balance_tokens) > balance_decimal
|
|
2099
|
+
):
|
|
2100
|
+
balance_tokens = math.nextafter(balance_tokens, 0.0)
|
|
2101
|
+
price = float(asset.get("price_usd") or 0.0)
|
|
2102
|
+
balances[checksum] = {
|
|
2103
|
+
"token": token,
|
|
2104
|
+
"address": checksum,
|
|
2105
|
+
"wei": int(balance_wei),
|
|
2106
|
+
"tokens": balance_tokens,
|
|
2107
|
+
"usd": balance_tokens * price,
|
|
2108
|
+
"asset": asset,
|
|
2109
|
+
"decimals": decimals,
|
|
2110
|
+
}
|
|
2111
|
+
return balances
|
|
2112
|
+
|
|
2113
|
+
async def _status(self) -> StatusDict:
|
|
2114
|
+
if not self.current_token:
|
|
2115
|
+
await self._hydrate_position_from_chain()
|
|
2116
|
+
_, net_deposit = await self.ledger_adapter.get_strategy_net_deposit(
|
|
2117
|
+
wallet_address=self._get_strategy_wallet_address()
|
|
2118
|
+
)
|
|
2119
|
+
snapshot = await self._get_assets_snapshot()
|
|
2120
|
+
lent_positions = await self._get_lent_positions(snapshot)
|
|
2121
|
+
asset_map = (
|
|
2122
|
+
snapshot.get("_by_underlying", {}) if isinstance(snapshot, dict) else {}
|
|
2123
|
+
)
|
|
2124
|
+
wallet_balances = await self._wallet_balances_from_snapshot(snapshot)
|
|
2125
|
+
position_rows = []
|
|
2126
|
+
total_usd = 0.0
|
|
2127
|
+
for entry in lent_positions.values():
|
|
2128
|
+
token = entry.get("token")
|
|
2129
|
+
amount_wei = entry.get("amount_wei", 0)
|
|
2130
|
+
amount = float(amount_wei) / (10 ** token.get("decimals"))
|
|
2131
|
+
asset = entry.get("asset")
|
|
2132
|
+
if not asset:
|
|
2133
|
+
checksum = self._token_checksum(token)
|
|
2134
|
+
asset = asset_map.get(checksum) if checksum else None
|
|
2135
|
+
if asset and asset.get("price_usd") is not None:
|
|
2136
|
+
try:
|
|
2137
|
+
price = float(asset.get("price_usd"))
|
|
2138
|
+
except (TypeError, ValueError):
|
|
2139
|
+
price = 0.0
|
|
2140
|
+
apy = float(asset.get("supply_apy") or 0.0) if asset else 0.0
|
|
2141
|
+
display_symbol = asset.get("symbol_display") if asset else token.symbol
|
|
2142
|
+
if asset and asset.get("supply_usd") is not None:
|
|
2143
|
+
try:
|
|
2144
|
+
balance_usd = float(asset.get("supply_usd"))
|
|
2145
|
+
except (TypeError, ValueError):
|
|
2146
|
+
balance_usd = amount * price
|
|
2147
|
+
else:
|
|
2148
|
+
balance_usd = amount * price
|
|
2149
|
+
total_usd += balance_usd
|
|
2150
|
+
position_rows.append(
|
|
2151
|
+
{
|
|
2152
|
+
"asset": display_symbol,
|
|
2153
|
+
"balance": amount,
|
|
2154
|
+
"apy": apy,
|
|
2155
|
+
"balance_usd": balance_usd,
|
|
2156
|
+
}
|
|
2157
|
+
)
|
|
2158
|
+
|
|
2159
|
+
(
|
|
2160
|
+
success,
|
|
2161
|
+
strategy_hype_balance_wei,
|
|
2162
|
+
) = await self.balance_adapter.get_balance(
|
|
2163
|
+
token_id=self.hype_token_info.get("token_id"),
|
|
2164
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
2165
|
+
)
|
|
2166
|
+
hype_price = asset_map.get(WRAPPED_HYPE_ADDRESS, {}).get("price_usd") or 0.0
|
|
2167
|
+
hype_value = 0.0
|
|
2168
|
+
if hype_price and success:
|
|
2169
|
+
hype_value = (
|
|
2170
|
+
strategy_hype_balance_wei
|
|
2171
|
+
/ (10 ** self.hype_token_info.get("decimals"))
|
|
2172
|
+
* hype_price
|
|
2173
|
+
)
|
|
2174
|
+
|
|
2175
|
+
idle_value = 0.0
|
|
2176
|
+
idle_tokens = 0.0
|
|
2177
|
+
if self.current_token:
|
|
2178
|
+
current_checksum = self._token_checksum(self.current_token)
|
|
2179
|
+
entry = wallet_balances.get(current_checksum) if current_checksum else None
|
|
2180
|
+
if entry:
|
|
2181
|
+
idle_tokens = entry.get("tokens") or 0.0
|
|
2182
|
+
asset = asset_map.get(current_checksum) if current_checksum else None
|
|
2183
|
+
if asset and asset.get("price_usd") is not None:
|
|
2184
|
+
try:
|
|
2185
|
+
idle_price = float(asset.get("price_usd"))
|
|
2186
|
+
except (TypeError, ValueError):
|
|
2187
|
+
idle_price = 1.0
|
|
2188
|
+
idle_value = idle_tokens * idle_price
|
|
2189
|
+
excludes = {
|
|
2190
|
+
self._token_checksum(entry["token"]) for entry in lent_positions.values()
|
|
2191
|
+
}
|
|
2192
|
+
if self.current_token:
|
|
2193
|
+
excludes.add(self._token_checksum(self.current_token))
|
|
2194
|
+
remaining_tokens = [
|
|
2195
|
+
(value["token"].get("asset_id"), value["usd"])
|
|
2196
|
+
for addr, value in wallet_balances.items()
|
|
2197
|
+
if addr not in excludes
|
|
2198
|
+
]
|
|
2199
|
+
remaining_usd = sum([usd for _, usd in remaining_tokens])
|
|
2200
|
+
total_portfolio_value = total_usd + idle_value + remaining_usd
|
|
2201
|
+
|
|
2202
|
+
status_payload: dict[str, Any] = {
|
|
2203
|
+
"lent_asset": self.current_symbol,
|
|
2204
|
+
"lent_balance": 0.0,
|
|
2205
|
+
"current_apy": float(self.current_avg_apy or 0.0),
|
|
2206
|
+
"positions": position_rows,
|
|
2207
|
+
"hype_buffer_tokens": strategy_hype_balance_wei
|
|
2208
|
+
/ (10 ** self.hype_token_info.get("decimals")),
|
|
2209
|
+
"hype_buffer_usd": hype_value,
|
|
2210
|
+
"idle_tokens": idle_tokens,
|
|
2211
|
+
"idle_usd": idle_value,
|
|
2212
|
+
"other_tokens": remaining_tokens,
|
|
2213
|
+
"other_balance_usd": remaining_usd,
|
|
2214
|
+
"rebalance_threshold": self.APY_REBALANCE_THRESHOLD,
|
|
2215
|
+
"short_circuit_threshold": self.APY_SHORT_CIRCUIT_THRESHOLD,
|
|
2216
|
+
"rotation_cooldown_hours": self.ROTATION_COOLDOWN.total_seconds() / 3600,
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
if position_rows:
|
|
2220
|
+
current_row = next(
|
|
2221
|
+
(row for row in position_rows if row["asset"] == self.current_symbol),
|
|
2222
|
+
position_rows[0],
|
|
2223
|
+
)
|
|
2224
|
+
status_payload["lent_asset"] = self._display_symbol(
|
|
2225
|
+
self.current_symbol or current_row["asset"]
|
|
2226
|
+
)
|
|
2227
|
+
status_payload["lent_balance"] = current_row["balance"]
|
|
2228
|
+
status_payload["current_apy"] = current_row["apy"]
|
|
2229
|
+
|
|
2230
|
+
if self.current_token:
|
|
2231
|
+
status_payload["current_asset_address"] = self.current_token.get("address")
|
|
2232
|
+
|
|
2233
|
+
if self.last_summary is not None and not self.last_summary.empty:
|
|
2234
|
+
top = self.last_summary.iloc[0]
|
|
2235
|
+
best_asset = str(top.get("asset"))
|
|
2236
|
+
expected_hourly = self._log_yield_to_hourly(
|
|
2237
|
+
float(top.get("E_cum_log_yield", 0.0))
|
|
2238
|
+
)
|
|
2239
|
+
status_payload.update(
|
|
2240
|
+
{
|
|
2241
|
+
"best_candidate": self._display_symbol(best_asset),
|
|
2242
|
+
"best_candidate_expected_apy": self._hourly_to_apy(expected_hourly),
|
|
2243
|
+
}
|
|
2244
|
+
)
|
|
2245
|
+
|
|
2246
|
+
return {
|
|
2247
|
+
"portfolio_value": total_portfolio_value,
|
|
2248
|
+
"net_deposit": net_deposit or 0.0,
|
|
2249
|
+
"strategy_status": status_payload,
|
|
2250
|
+
"gas_available": strategy_hype_balance_wei
|
|
2251
|
+
/ (10 ** self.hype_token_info.get("decimals")),
|
|
2252
|
+
"gassed_up": self.GAS_MAXIMUM / 3
|
|
2253
|
+
<= strategy_hype_balance_wei / (10 ** self.hype_token_info.get("decimals")),
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
@staticmethod
|
|
2257
|
+
async def policies() -> list[str]:
|
|
2258
|
+
"""Return policy strings used to scope on-chain permissions."""
|
|
2259
|
+
return [
|
|
2260
|
+
any_hyperliquid_l1_payload(),
|
|
2261
|
+
any_hyperliquid_user_payload(),
|
|
2262
|
+
hypecore_sentinel_deposit(),
|
|
2263
|
+
await whype_deposit_and_withdraw(),
|
|
2264
|
+
erc20_spender_for_any_token(HYPERLEND_POOL),
|
|
2265
|
+
await hyperlend_supply_and_withdraw(),
|
|
2266
|
+
erc20_spender_for_any_token(ENSO_ROUTER),
|
|
2267
|
+
await enso_swap(),
|
|
2268
|
+
erc20_spender_for_any_token(PRJX_ROUTER),
|
|
2269
|
+
await prjx_swap(),
|
|
2270
|
+
]
|