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,1810 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import math
|
|
3
|
+
import time
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from wayfinder_paths.adapters.balance_adapter.adapter import BalanceAdapter
|
|
10
|
+
from wayfinder_paths.adapters.brap_adapter.adapter import BRAPAdapter
|
|
11
|
+
from wayfinder_paths.adapters.ledger_adapter.adapter import LedgerAdapter
|
|
12
|
+
from wayfinder_paths.adapters.pool_adapter.adapter import PoolAdapter
|
|
13
|
+
from wayfinder_paths.adapters.token_adapter.adapter import TokenAdapter
|
|
14
|
+
from wayfinder_paths.core.constants.base import DEFAULT_SLIPPAGE
|
|
15
|
+
from wayfinder_paths.core.services.local_token_txn import (
|
|
16
|
+
LocalTokenTxnService,
|
|
17
|
+
)
|
|
18
|
+
from wayfinder_paths.core.services.web3_service import DefaultWeb3Service
|
|
19
|
+
from wayfinder_paths.core.strategies.descriptors import (
|
|
20
|
+
Complexity,
|
|
21
|
+
Directionality,
|
|
22
|
+
Frequency,
|
|
23
|
+
StratDescriptor,
|
|
24
|
+
TokenExposure,
|
|
25
|
+
Volatility,
|
|
26
|
+
)
|
|
27
|
+
from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
|
|
28
|
+
from wayfinder_paths.core.wallets.WalletManager import WalletManager
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StablecoinYieldStrategy(Strategy):
|
|
32
|
+
name = "Stablecoin Yield Strategy"
|
|
33
|
+
|
|
34
|
+
# Strategy parameters
|
|
35
|
+
MIN_AMOUNT_USDC = 2
|
|
36
|
+
MINIMUM_DAYS_UNTIL_PROFIT = 7
|
|
37
|
+
MIN_TVL = 1_000_000
|
|
38
|
+
DUST_APY = 0.01
|
|
39
|
+
MIN_GAS = 10e-4 # ethereum float
|
|
40
|
+
SEARCH_DEPTH = 10
|
|
41
|
+
SUPPORTED_NETWORK_CODES = {"base"}
|
|
42
|
+
ROTATION_MIN_INTERVAL = timedelta(days=14)
|
|
43
|
+
MINIMUM_APY_IMPROVEMENT = 0.01
|
|
44
|
+
GAS_MAXIMUM = 10e-4 # ethereum float
|
|
45
|
+
GAS_SAFETY_FRACTION = 1 / 3
|
|
46
|
+
|
|
47
|
+
INFO = StratDescriptor(
|
|
48
|
+
description=(
|
|
49
|
+
"An automated yield optimization strategy that maximizes returns on USDC deposits on Base.\n\n"
|
|
50
|
+
"What it does: Continuously scans and evaluates yield opportunities across Base-based DeFi protocols to find the "
|
|
51
|
+
"highest-yielding, low-risk positions for USDC. Automatically rebalances positions when better opportunities "
|
|
52
|
+
"emerge to maintain optimal yield generation.\n\n"
|
|
53
|
+
"Exposure type: Stable USD-denominated exposure with minimal impermanent loss risk. Focuses exclusively on USDC "
|
|
54
|
+
"and operations on the Base network to preserve capital and maximize yield.\n\n"
|
|
55
|
+
"Chains: Operates solely on the Base network.\n\n"
|
|
56
|
+
f"Deposit/Withdrawal: Accepts deposits only in USDC on Base with a minimum of {MIN_AMOUNT_USDC} USDC. Gas: Requires Base ETH "
|
|
57
|
+
"for gas fees during position entry, rebalancing, and exit (~0.001-0.02 ETH per rebalance cycle). Strategy automatically "
|
|
58
|
+
"deploys funds to an optimal yield farming position on Base. Withdrawals exit current positions and return USDC to the "
|
|
59
|
+
"user wallet.\n\n"
|
|
60
|
+
f"Risks: Primary risks include smart contract vulnerabilities in underlying Base DeFi protocols, temporary yield fluctuations, "
|
|
61
|
+
f"gas costs during rebalancing, and potential brief capital lock-up during protocol transitions. Strategy filters for a minimum TVL of ${MIN_TVL:,}."
|
|
62
|
+
),
|
|
63
|
+
summary=(
|
|
64
|
+
"Automated stablecoin yield farming across DeFi protocols on Base. "
|
|
65
|
+
f"Continuously optimizes positions for maximum stable yield while avoiding impermanent loss. "
|
|
66
|
+
f"Min: {MIN_AMOUNT_USDC} USDC + ETH gas. Filters for ${MIN_TVL:,}+ TVL protocols."
|
|
67
|
+
),
|
|
68
|
+
gas_token_symbol="ETH",
|
|
69
|
+
gas_token_id="ethereum-base",
|
|
70
|
+
deposit_token_id="usd-coin-base",
|
|
71
|
+
minimum_net_deposit=50,
|
|
72
|
+
gas_maximum=GAS_MAXIMUM,
|
|
73
|
+
# Anything below this level triggers a gas top-up
|
|
74
|
+
gas_threshold=GAS_MAXIMUM * GAS_SAFETY_FRACTION,
|
|
75
|
+
# risk indicators
|
|
76
|
+
volatility=Volatility.LOW,
|
|
77
|
+
volatility_description_short=(
|
|
78
|
+
"Capital sits in Base stablecoin lending pools, so price swings are minimal."
|
|
79
|
+
),
|
|
80
|
+
directionality=Directionality.MARKET_NEUTRAL,
|
|
81
|
+
directionality_description=(
|
|
82
|
+
"Fully USD-denominated yield farming with no directional crypto beta."
|
|
83
|
+
),
|
|
84
|
+
complexity=Complexity.LOW,
|
|
85
|
+
complexity_description="Agent handles optimal pool finding and rebalancing",
|
|
86
|
+
token_exposure=TokenExposure.STABLECOINS,
|
|
87
|
+
token_exposure_description=(
|
|
88
|
+
"Only Base USDC (and occasional stable swaps) with no volatile assets."
|
|
89
|
+
),
|
|
90
|
+
frequency=Frequency.LOW,
|
|
91
|
+
frequency_description=(
|
|
92
|
+
"Updates every 2 hours; rebalances infrequent (bi-weekly cooldowns)."
|
|
93
|
+
),
|
|
94
|
+
return_drivers=["pool yield"],
|
|
95
|
+
# config metadata for UIs/agents
|
|
96
|
+
config={
|
|
97
|
+
"deposit": {
|
|
98
|
+
"parameters": {
|
|
99
|
+
"main_token_amount": {
|
|
100
|
+
"type": "float",
|
|
101
|
+
"description": "amount of Base USDC (token id: usd-coin-base) to deposit",
|
|
102
|
+
},
|
|
103
|
+
"gas_token_amount": {
|
|
104
|
+
"type": "float",
|
|
105
|
+
"description": "amount of Base ETH (token id: ethereum-base) to deposit for gas fees",
|
|
106
|
+
"minimum": 0,
|
|
107
|
+
"maximum": GAS_MAXIMUM,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
"process": "Deposits USDC on Base and searches for the highest yield opportunities among Base-based DeFi protocols",
|
|
111
|
+
"requirements": [
|
|
112
|
+
"Sufficient USDC balance on Base",
|
|
113
|
+
"Base ETH available for gas",
|
|
114
|
+
],
|
|
115
|
+
"result": "Funds deployed to a yield farming position on Base",
|
|
116
|
+
},
|
|
117
|
+
"withdraw": {
|
|
118
|
+
"parameters": {},
|
|
119
|
+
"process": "Exits yield positions on Base and returns USDC to the user wallet",
|
|
120
|
+
"requirements": [
|
|
121
|
+
"Active positions to exit",
|
|
122
|
+
"Gas for transactions on Base",
|
|
123
|
+
],
|
|
124
|
+
"result": "USDC returned to wallet and positions closed on Base",
|
|
125
|
+
},
|
|
126
|
+
"update": {
|
|
127
|
+
"parameters": {},
|
|
128
|
+
"process": "Scans for better yield opportunities on Base and rebalances positions automatically",
|
|
129
|
+
"frequency": "Call daily or when significant yield changes occur",
|
|
130
|
+
"requirements": [
|
|
131
|
+
"Active strategy positions on Base",
|
|
132
|
+
"Sufficient Base gas for rebalancing",
|
|
133
|
+
],
|
|
134
|
+
"result": "Positions optimized for maximum yield on Base",
|
|
135
|
+
},
|
|
136
|
+
"technical_details": {
|
|
137
|
+
"wallet_structure": "Uses strategy subwallet for isolation",
|
|
138
|
+
"chains": ["Base"],
|
|
139
|
+
"protocols": ["Various Base DeFi yield protocols"],
|
|
140
|
+
"tokens": ["USDC"],
|
|
141
|
+
"gas_requirements": "~0.001-0.02 ETH per rebalance on Base",
|
|
142
|
+
"search_depth": SEARCH_DEPTH,
|
|
143
|
+
"minimum_tvl": MIN_TVL,
|
|
144
|
+
"dust_apy_threshold": DUST_APY,
|
|
145
|
+
"minimum_apy_edge": MINIMUM_APY_IMPROVEMENT,
|
|
146
|
+
"rotation_cooldown_days": ROTATION_MIN_INTERVAL.days,
|
|
147
|
+
"profit_horizon_days": MINIMUM_DAYS_UNTIL_PROFIT,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
config: dict[str, Any] | None = None,
|
|
155
|
+
*,
|
|
156
|
+
main_wallet: dict[str, Any] | None = None,
|
|
157
|
+
strategy_wallet: dict[str, Any] | None = None,
|
|
158
|
+
simulation: bool = False,
|
|
159
|
+
web3_service=None,
|
|
160
|
+
api_key: str | None = None,
|
|
161
|
+
):
|
|
162
|
+
super().__init__(api_key=api_key)
|
|
163
|
+
merged_config: dict[str, Any] = dict(config or {})
|
|
164
|
+
if main_wallet is not None:
|
|
165
|
+
merged_config["main_wallet"] = main_wallet
|
|
166
|
+
if strategy_wallet is not None:
|
|
167
|
+
merged_config["strategy_wallet"] = strategy_wallet
|
|
168
|
+
|
|
169
|
+
self.config = merged_config
|
|
170
|
+
self.simulation = simulation
|
|
171
|
+
self.deposited_amount = 0
|
|
172
|
+
self.current_pool = None
|
|
173
|
+
self.current_apy = 0
|
|
174
|
+
self.balance_adapter = None
|
|
175
|
+
self.tx_adapter = None
|
|
176
|
+
self.web3_service = web3_service
|
|
177
|
+
self.token_adapter = None
|
|
178
|
+
self.ledger_adapter = None
|
|
179
|
+
self.pool_adapter = None
|
|
180
|
+
self.brap_adapter = None
|
|
181
|
+
|
|
182
|
+
# State tracking for deterministic token management
|
|
183
|
+
self.tracked_token_ids: set[str] = set() # All tokens strategy might hold
|
|
184
|
+
self.tracked_balances: dict[str, int] = {} # token_id -> balance in wei
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
main_wallet_cfg = self.config.get("main_wallet")
|
|
188
|
+
strategy_wallet_cfg = self.config.get("strategy_wallet")
|
|
189
|
+
|
|
190
|
+
adapter_config = {
|
|
191
|
+
"main_wallet": main_wallet_cfg or None,
|
|
192
|
+
"strategy_wallet": strategy_wallet_cfg or None,
|
|
193
|
+
"strategy": self.config,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if self.web3_service is None:
|
|
197
|
+
wallet_provider = WalletManager.get_provider(adapter_config)
|
|
198
|
+
tx_adapter = LocalTokenTxnService(
|
|
199
|
+
adapter_config,
|
|
200
|
+
wallet_provider=wallet_provider,
|
|
201
|
+
simulation=self.simulation,
|
|
202
|
+
)
|
|
203
|
+
web3_service = DefaultWeb3Service(
|
|
204
|
+
wallet_provider=wallet_provider, evm_transactions=tx_adapter
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
web3_service = self.web3_service
|
|
208
|
+
tx_adapter = web3_service.token_transactions
|
|
209
|
+
balance = BalanceAdapter(adapter_config, web3_service=web3_service)
|
|
210
|
+
token_adapter = TokenAdapter()
|
|
211
|
+
ledger_adapter = LedgerAdapter()
|
|
212
|
+
pool_adapter = PoolAdapter()
|
|
213
|
+
brap_adapter = BRAPAdapter(
|
|
214
|
+
web3_service=web3_service, simulation=self.simulation
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
self.register_adapters(
|
|
218
|
+
[
|
|
219
|
+
balance,
|
|
220
|
+
token_adapter,
|
|
221
|
+
ledger_adapter,
|
|
222
|
+
pool_adapter,
|
|
223
|
+
brap_adapter,
|
|
224
|
+
tx_adapter,
|
|
225
|
+
]
|
|
226
|
+
)
|
|
227
|
+
self.balance_adapter = balance
|
|
228
|
+
self.tx_adapter = tx_adapter
|
|
229
|
+
self.web3_service = web3_service
|
|
230
|
+
self.token_adapter = token_adapter
|
|
231
|
+
self.ledger_adapter = ledger_adapter
|
|
232
|
+
self.pool_adapter = pool_adapter
|
|
233
|
+
self.brap_adapter = brap_adapter
|
|
234
|
+
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
def _get_strategy_wallet_address(self) -> str:
|
|
239
|
+
"""Get strategy wallet address with validation."""
|
|
240
|
+
strategy_wallet = self.config.get("strategy_wallet")
|
|
241
|
+
if not strategy_wallet or not isinstance(strategy_wallet, dict):
|
|
242
|
+
raise ValueError("strategy_wallet not configured in strategy config")
|
|
243
|
+
address = strategy_wallet.get("address")
|
|
244
|
+
if not address:
|
|
245
|
+
raise ValueError("strategy_wallet address not found in config")
|
|
246
|
+
return str(address)
|
|
247
|
+
|
|
248
|
+
def _get_main_wallet_address(self) -> str:
|
|
249
|
+
"""Get main wallet address with validation."""
|
|
250
|
+
main_wallet = self.config.get("main_wallet")
|
|
251
|
+
if not main_wallet or not isinstance(main_wallet, dict):
|
|
252
|
+
raise ValueError("main_wallet not configured in strategy config")
|
|
253
|
+
address = main_wallet.get("address")
|
|
254
|
+
if not address:
|
|
255
|
+
raise ValueError("main_wallet address not found in config")
|
|
256
|
+
return str(address)
|
|
257
|
+
|
|
258
|
+
def _track_token(self, token_id: str, balance_wei: int = 0):
|
|
259
|
+
"""Track a token that the strategy holds or might hold."""
|
|
260
|
+
if token_id:
|
|
261
|
+
self.tracked_token_ids.add(token_id)
|
|
262
|
+
if balance_wei > 0:
|
|
263
|
+
self.tracked_balances[token_id] = balance_wei
|
|
264
|
+
|
|
265
|
+
def _update_balance(self, token_id: str, balance_wei: int):
|
|
266
|
+
"""Update the tracked balance for a token."""
|
|
267
|
+
if token_id:
|
|
268
|
+
self.tracked_balances[token_id] = balance_wei
|
|
269
|
+
if balance_wei > 0:
|
|
270
|
+
self.tracked_token_ids.add(token_id)
|
|
271
|
+
|
|
272
|
+
async def _refresh_tracked_balances(self):
|
|
273
|
+
"""Refresh balances for all tracked tokens from on-chain data."""
|
|
274
|
+
strategy_address = self._get_strategy_wallet_address()
|
|
275
|
+
for token_id in self.tracked_token_ids:
|
|
276
|
+
try:
|
|
277
|
+
success, balance_wei = await self.balance_adapter.get_balance(
|
|
278
|
+
token_id=token_id,
|
|
279
|
+
wallet_address=strategy_address,
|
|
280
|
+
)
|
|
281
|
+
if success and balance_wei:
|
|
282
|
+
self.tracked_balances[token_id] = int(balance_wei)
|
|
283
|
+
else:
|
|
284
|
+
self.tracked_balances[token_id] = 0
|
|
285
|
+
except Exception as e:
|
|
286
|
+
logger.warning(f"Failed to refresh balance for {token_id}: {e}")
|
|
287
|
+
self.tracked_balances[token_id] = 0
|
|
288
|
+
|
|
289
|
+
def _get_non_zero_tracked_tokens(self) -> list[tuple[str, int]]:
|
|
290
|
+
"""Get list of (token_id, balance_wei) for tokens with non-zero balances."""
|
|
291
|
+
return [
|
|
292
|
+
(token_id, balance)
|
|
293
|
+
for token_id, balance in self.tracked_balances.items()
|
|
294
|
+
if balance > 0
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
async def setup(self):
|
|
298
|
+
logger.info("Starting StablecoinYieldStrategy setup")
|
|
299
|
+
start_time = time.time()
|
|
300
|
+
|
|
301
|
+
await super().setup()
|
|
302
|
+
self.current_combined_apy_pct = 0.0
|
|
303
|
+
|
|
304
|
+
# Get strategy net deposit
|
|
305
|
+
try:
|
|
306
|
+
logger.info("Fetching strategy net deposit from ledger")
|
|
307
|
+
strategy_address = self._get_strategy_wallet_address()
|
|
308
|
+
success, deposit_data = await self.ledger_adapter.get_strategy_net_deposit(
|
|
309
|
+
wallet_address=strategy_address,
|
|
310
|
+
)
|
|
311
|
+
if success:
|
|
312
|
+
self.DEPOSIT_USDC = deposit_data.get("net_deposit", 0)
|
|
313
|
+
logger.info(f"Strategy net deposit: {self.DEPOSIT_USDC} USDC")
|
|
314
|
+
else:
|
|
315
|
+
logger.error(f"Failed to fetch strategy net deposit: {deposit_data}")
|
|
316
|
+
self.DEPOSIT_USDC = 0
|
|
317
|
+
except Exception as e:
|
|
318
|
+
logger.error(f"Failed to fetch strategy net deposit: {e}")
|
|
319
|
+
self.DEPOSIT_USDC = 0
|
|
320
|
+
|
|
321
|
+
# Get USDC token info
|
|
322
|
+
try:
|
|
323
|
+
logger.info("Fetching USDC token information")
|
|
324
|
+
success, self.usdc_token_info = await self.token_adapter.get_token(
|
|
325
|
+
"usd-coin-base"
|
|
326
|
+
)
|
|
327
|
+
if not success:
|
|
328
|
+
logger.warning("Failed to fetch USDC token info, using empty dict")
|
|
329
|
+
self.usdc_token_info = {}
|
|
330
|
+
else:
|
|
331
|
+
logger.info(
|
|
332
|
+
f"USDC token info loaded: {self.usdc_token_info.get('symbol', 'Unknown')} on {self.usdc_token_info.get('chain', {}).get('name', 'Unknown')}"
|
|
333
|
+
)
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error(f"Error fetching USDC token info: {e}")
|
|
336
|
+
self.usdc_token_info = {}
|
|
337
|
+
|
|
338
|
+
# Always track USDC as baseline token
|
|
339
|
+
if self.usdc_token_info.get("token_id"):
|
|
340
|
+
self._track_token(self.usdc_token_info.get("token_id"))
|
|
341
|
+
|
|
342
|
+
self.current_pool = {
|
|
343
|
+
"token_id": self.usdc_token_info.get("token_id"),
|
|
344
|
+
"name": self.usdc_token_info.get("name"),
|
|
345
|
+
"symbol": self.usdc_token_info.get("symbol"),
|
|
346
|
+
"decimals": self.usdc_token_info.get("decimals", 18),
|
|
347
|
+
"address": self.usdc_token_info.get("address"),
|
|
348
|
+
"chain": self.usdc_token_info.get("chain", {"code": "base", "id": 8453}),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
self.current_pool_data = None
|
|
352
|
+
|
|
353
|
+
chain_code = "base" # Default to base
|
|
354
|
+
if self.current_pool and self.current_pool.get("chain"):
|
|
355
|
+
chain_code = self.current_pool.get("chain").get("code", "base")
|
|
356
|
+
|
|
357
|
+
# Get gas token info
|
|
358
|
+
try:
|
|
359
|
+
logger.info(f"Fetching gas token for chain: {chain_code}")
|
|
360
|
+
success, gas_token_data = await self.token_adapter.get_gas_token(chain_code)
|
|
361
|
+
if success:
|
|
362
|
+
self.gas_token = gas_token_data
|
|
363
|
+
logger.info(
|
|
364
|
+
f"Gas token loaded: {gas_token_data.get('symbol', 'Unknown')}"
|
|
365
|
+
)
|
|
366
|
+
# Track gas token (but don't count it as a strategy asset)
|
|
367
|
+
if self.gas_token.get("id"):
|
|
368
|
+
self._track_token(self.gas_token.get("id"))
|
|
369
|
+
else:
|
|
370
|
+
logger.warning("Failed to fetch gas token info, using empty dict")
|
|
371
|
+
self.gas_token = {}
|
|
372
|
+
except Exception as e:
|
|
373
|
+
logger.error(f"Error fetching gas token info: {e}")
|
|
374
|
+
self.gas_token = {}
|
|
375
|
+
|
|
376
|
+
if not self.DEPOSIT_USDC:
|
|
377
|
+
logger.info("No deposits found, setting current pool balance to 0")
|
|
378
|
+
self.current_pool_balance = 0
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
# Get strategy transactions to determine current position and build tracked token set
|
|
382
|
+
try:
|
|
383
|
+
logger.info("Fetching strategy transaction history to build state")
|
|
384
|
+
success, txns_data = await self.ledger_adapter.get_strategy_transactions(
|
|
385
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
386
|
+
)
|
|
387
|
+
if success:
|
|
388
|
+
txns = [
|
|
389
|
+
txn
|
|
390
|
+
for txn in txns_data.get("transactions", [])
|
|
391
|
+
if txn.get("operation") != "DEPOSIT"
|
|
392
|
+
]
|
|
393
|
+
logger.info(f"Found {len(txns)} non-deposit transactions")
|
|
394
|
+
|
|
395
|
+
# Build tracked token set from transaction history
|
|
396
|
+
for txn in txns:
|
|
397
|
+
op_data = txn.get("data", {}).get("op_data", {})
|
|
398
|
+
# Track any token that was swapped TO
|
|
399
|
+
if op_data.get("to_token_id"):
|
|
400
|
+
self._track_token(op_data.get("to_token_id"))
|
|
401
|
+
# Track any token that was swapped FROM
|
|
402
|
+
if op_data.get("from_token_id"):
|
|
403
|
+
self._track_token(op_data.get("from_token_id"))
|
|
404
|
+
|
|
405
|
+
logger.info(
|
|
406
|
+
f"Tracking {len(self.tracked_token_ids)} tokens from history"
|
|
407
|
+
)
|
|
408
|
+
else:
|
|
409
|
+
logger.error(f"Failed to fetch strategy transactions: {txns_data}")
|
|
410
|
+
txns = []
|
|
411
|
+
except Exception as e:
|
|
412
|
+
logger.error(f"Failed to fetch strategy transactions: {e}")
|
|
413
|
+
txns = []
|
|
414
|
+
|
|
415
|
+
if txns and txns[-1].get("operation") != "WITHDRAW":
|
|
416
|
+
pos = txns[-1].get("data").get("op_data")
|
|
417
|
+
success, token_info = await self.token_adapter.get_token(
|
|
418
|
+
pos.get("to_token_id")
|
|
419
|
+
)
|
|
420
|
+
if not success:
|
|
421
|
+
token_info = {}
|
|
422
|
+
self.current_pool = {
|
|
423
|
+
"token_id": token_info.get("token_id"),
|
|
424
|
+
"name": token_info.get("name"),
|
|
425
|
+
"symbol": token_info.get("symbol"),
|
|
426
|
+
"decimals": token_info.get("decimals"),
|
|
427
|
+
"address": token_info.get("address"),
|
|
428
|
+
"chain": token_info.get("chain"),
|
|
429
|
+
}
|
|
430
|
+
# Track the current pool token
|
|
431
|
+
if token_info.get("token_id"):
|
|
432
|
+
self._track_token(token_info.get("token_id"))
|
|
433
|
+
|
|
434
|
+
success, reports = await self.pool_adapter.get_pools_by_ids(
|
|
435
|
+
pool_ids=[self.current_pool.get("token_id")],
|
|
436
|
+
merge_external=False,
|
|
437
|
+
)
|
|
438
|
+
if success and reports.get("pools"):
|
|
439
|
+
self.current_pool_data = reports.get("pools", [])[0]
|
|
440
|
+
|
|
441
|
+
identifiers = []
|
|
442
|
+
pool_id = self.current_pool.get("token_id", None)
|
|
443
|
+
if isinstance(pool_id, str):
|
|
444
|
+
identifiers.append(pool_id)
|
|
445
|
+
|
|
446
|
+
pool_address = self.current_pool.get("address", None)
|
|
447
|
+
pool_chain = self.current_pool.get("chain", None)
|
|
448
|
+
chain_code = ((pool_chain or {}).get("code")) or None
|
|
449
|
+
if isinstance(pool_address, str) and isinstance(chain_code, str):
|
|
450
|
+
identifiers.append(f"{chain_code.lower()}_{pool_address.lower()}")
|
|
451
|
+
|
|
452
|
+
llama_report = None
|
|
453
|
+
if identifiers:
|
|
454
|
+
success, llama_reports = await self.pool_adapter.get_llama_reports(
|
|
455
|
+
identifiers=identifiers
|
|
456
|
+
)
|
|
457
|
+
if success:
|
|
458
|
+
for identifier in identifiers:
|
|
459
|
+
if not isinstance(identifier, str):
|
|
460
|
+
continue
|
|
461
|
+
report = llama_reports.get(identifier.lower(), None)
|
|
462
|
+
if report:
|
|
463
|
+
llama_report = report
|
|
464
|
+
break
|
|
465
|
+
|
|
466
|
+
if self.current_pool_data is None and llama_report:
|
|
467
|
+
self.current_pool_data = {
|
|
468
|
+
**self.current_pool_data,
|
|
469
|
+
"llama_report": llama_report,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if llama_report and llama_report.get("llama_combined_apy_pct") is not None:
|
|
473
|
+
self.current_combined_apy_pct = (
|
|
474
|
+
llama_report.get("llama_combined_apy_pct", 0) / 100
|
|
475
|
+
)
|
|
476
|
+
elif llama_report and llama_report.get("llama_apy_pct") is not None:
|
|
477
|
+
self.current_combined_apy_pct = llama_report.get("llama_apy_pct", 0) / 100
|
|
478
|
+
elif self.current_pool_data:
|
|
479
|
+
self.current_combined_apy_pct = self.current_pool_data.get("apy", 0)
|
|
480
|
+
|
|
481
|
+
pool_address = self.current_pool.get("address")
|
|
482
|
+
chain_id = self.current_pool.get("chain", {}).get("id")
|
|
483
|
+
user_address = self._get_strategy_wallet_address()
|
|
484
|
+
if (
|
|
485
|
+
pool_address
|
|
486
|
+
and chain_id
|
|
487
|
+
and user_address
|
|
488
|
+
and pool_address != self.usdc_token_info.get("address")
|
|
489
|
+
):
|
|
490
|
+
try:
|
|
491
|
+
(
|
|
492
|
+
success,
|
|
493
|
+
current_pool_balance_raw,
|
|
494
|
+
) = await self.balance_adapter.get_pool_balance(
|
|
495
|
+
pool_address=pool_address,
|
|
496
|
+
chain_id=chain_id,
|
|
497
|
+
user_address=user_address,
|
|
498
|
+
)
|
|
499
|
+
self.current_pool_balance = current_pool_balance_raw if success else 0
|
|
500
|
+
except Exception as e:
|
|
501
|
+
print(f"Warning: Failed to get pool balance: {e}")
|
|
502
|
+
self.current_pool_balance = 0
|
|
503
|
+
else:
|
|
504
|
+
self.current_pool_balance = 0
|
|
505
|
+
|
|
506
|
+
baseline_token = (
|
|
507
|
+
self.usdc_token_info
|
|
508
|
+
if self.usdc_token_info.get("chain", {}).get("id")
|
|
509
|
+
== self.current_pool.get("chain").get("id")
|
|
510
|
+
else None
|
|
511
|
+
)
|
|
512
|
+
# Refresh all tracked balances from blockchain
|
|
513
|
+
await self._refresh_tracked_balances()
|
|
514
|
+
logger.info(
|
|
515
|
+
f"Refreshed balances for {len(self.tracked_balances)} tracked tokens"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if (
|
|
519
|
+
baseline_token
|
|
520
|
+
and self.current_pool.get("token_id") != baseline_token.get("token_id")
|
|
521
|
+
and self.current_pool_balance
|
|
522
|
+
):
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
# Fallback: Try to infer active pool from tracked tokens with balances
|
|
526
|
+
inferred = await self._infer_active_pool_from_tracked_tokens()
|
|
527
|
+
if inferred is not None:
|
|
528
|
+
inferred_token, inferred_balance, inferred_entry = inferred
|
|
529
|
+
self.current_pool = inferred_token
|
|
530
|
+
self.current_pool_balance = inferred_balance
|
|
531
|
+
if inferred_entry:
|
|
532
|
+
self.current_pool_data = inferred_entry
|
|
533
|
+
llama_combined = inferred_entry.get("llama_combined_apy_pct")
|
|
534
|
+
llama_apy = inferred_entry.get("llama_apy_pct")
|
|
535
|
+
if llama_combined is not None:
|
|
536
|
+
self.current_combined_apy_pct = float(llama_combined) / 100
|
|
537
|
+
elif llama_apy is not None:
|
|
538
|
+
self.current_combined_apy_pct = float(llama_apy) / 100
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
if self.usdc_token_info:
|
|
542
|
+
status, raw_balance = await self.balance_adapter.get_balance(
|
|
543
|
+
token_id=self.usdc_token_info.get("token_id"),
|
|
544
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
545
|
+
)
|
|
546
|
+
if not status or not raw_balance:
|
|
547
|
+
return
|
|
548
|
+
try:
|
|
549
|
+
balance_wei = int(raw_balance)
|
|
550
|
+
except (TypeError, ValueError):
|
|
551
|
+
return
|
|
552
|
+
if balance_wei <= 0:
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
self.current_pool = self.usdc_token_info
|
|
556
|
+
self.current_pool_balance = balance_wei
|
|
557
|
+
self.current_combined_apy_pct = 0.0
|
|
558
|
+
self.current_pool_data = None
|
|
559
|
+
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
elapsed_time = time.time() - start_time
|
|
563
|
+
logger.info(
|
|
564
|
+
f"StablecoinYieldStrategy setup completed in {elapsed_time:.2f} seconds"
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
def _sum_non_gas_balance_usd(self, balances: list[dict[str, Any]] | None) -> float:
|
|
568
|
+
total_usd = 0.0
|
|
569
|
+
for bal in balances or []:
|
|
570
|
+
if self._is_gas_balance_entry(bal):
|
|
571
|
+
continue
|
|
572
|
+
usd_value = bal.get("balanceUSD")
|
|
573
|
+
try:
|
|
574
|
+
total_usd += float(usd_value or 0.0)
|
|
575
|
+
except (TypeError, ValueError):
|
|
576
|
+
continue
|
|
577
|
+
return total_usd
|
|
578
|
+
|
|
579
|
+
async def _infer_active_pool_from_tracked_tokens(self):
|
|
580
|
+
"""Infer the active pool from tracked tokens with non-zero balances."""
|
|
581
|
+
try:
|
|
582
|
+
# Refresh balances for tracked tokens
|
|
583
|
+
await self._refresh_tracked_balances()
|
|
584
|
+
|
|
585
|
+
usdc_token_id = self.usdc_token_info.get("token_id")
|
|
586
|
+
gas_token_id = self.gas_token.get("id") if self.gas_token else None
|
|
587
|
+
|
|
588
|
+
best_token_id = None
|
|
589
|
+
best_balance_wei = 0
|
|
590
|
+
|
|
591
|
+
# Find the non-gas, non-USDC token with the largest balance
|
|
592
|
+
for token_id, balance_wei in self.tracked_balances.items():
|
|
593
|
+
if balance_wei <= 0:
|
|
594
|
+
continue
|
|
595
|
+
if token_id == gas_token_id:
|
|
596
|
+
continue
|
|
597
|
+
if token_id == usdc_token_id:
|
|
598
|
+
continue
|
|
599
|
+
|
|
600
|
+
# Prefer tokens with larger balances
|
|
601
|
+
if balance_wei > best_balance_wei:
|
|
602
|
+
best_token_id = token_id
|
|
603
|
+
best_balance_wei = balance_wei
|
|
604
|
+
|
|
605
|
+
if not best_token_id:
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
# Fetch token info
|
|
609
|
+
success, token = await self.token_adapter.get_token(best_token_id)
|
|
610
|
+
if not success:
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
# Get fresh on-chain balance
|
|
614
|
+
strategy_address = self._get_strategy_wallet_address()
|
|
615
|
+
try:
|
|
616
|
+
success, onchain_balance = await self.balance_adapter.get_balance(
|
|
617
|
+
token_id=token.get("token_id"),
|
|
618
|
+
wallet_address=strategy_address,
|
|
619
|
+
)
|
|
620
|
+
if success and onchain_balance:
|
|
621
|
+
best_balance_wei = int(onchain_balance)
|
|
622
|
+
except Exception:
|
|
623
|
+
pass
|
|
624
|
+
|
|
625
|
+
logger.info(
|
|
626
|
+
f"Inferred active pool: {token.get('symbol')} with balance {best_balance_wei}"
|
|
627
|
+
)
|
|
628
|
+
return token, best_balance_wei, None
|
|
629
|
+
|
|
630
|
+
except Exception as e:
|
|
631
|
+
logger.error(f"Failed to infer active pool from tracked tokens: {e}")
|
|
632
|
+
return None
|
|
633
|
+
|
|
634
|
+
def _is_gas_balance_entry(self, balance: dict[str, Any]) -> bool:
|
|
635
|
+
"""Check if a balance entry represents a gas token."""
|
|
636
|
+
if not self.gas_token:
|
|
637
|
+
return False
|
|
638
|
+
|
|
639
|
+
# Check by token ID
|
|
640
|
+
token_id = balance.get("token_id")
|
|
641
|
+
if (
|
|
642
|
+
isinstance(token_id, str)
|
|
643
|
+
and token_id.lower() == self.gas_token.get("id", "").lower()
|
|
644
|
+
):
|
|
645
|
+
return True
|
|
646
|
+
|
|
647
|
+
# Check by token address and network
|
|
648
|
+
token_address = balance.get("tokenAddress")
|
|
649
|
+
if isinstance(token_address, str):
|
|
650
|
+
if token_address.lower() == self.gas_token.get("address", "").lower():
|
|
651
|
+
return True
|
|
652
|
+
|
|
653
|
+
# Check address + network combination
|
|
654
|
+
network = (balance.get("network") or "").lower()
|
|
655
|
+
chain_code = self.current_pool.get("chain", {}).get("code", "").lower()
|
|
656
|
+
if (
|
|
657
|
+
token_address.lower() == self.gas_token.get("address", "").lower()
|
|
658
|
+
and network == chain_code
|
|
659
|
+
):
|
|
660
|
+
return True
|
|
661
|
+
|
|
662
|
+
return False
|
|
663
|
+
|
|
664
|
+
async def deposit(
|
|
665
|
+
self, main_token_amount: float = 0.0, gas_token_amount: float = 0.0
|
|
666
|
+
) -> StatusTuple:
|
|
667
|
+
if main_token_amount == 0.0 and gas_token_amount == 0.0:
|
|
668
|
+
return (
|
|
669
|
+
False,
|
|
670
|
+
"Either main_token_amount or gas_token_amount must be provided",
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
logger.info(
|
|
674
|
+
f"Starting deposit process for {main_token_amount} USDC and {gas_token_amount} gas"
|
|
675
|
+
)
|
|
676
|
+
start_time = time.time()
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
token_info = self.usdc_token_info
|
|
680
|
+
self.current_pool = {
|
|
681
|
+
"token_id": token_info.get("token_id"),
|
|
682
|
+
"name": token_info.get("name"),
|
|
683
|
+
"symbol": token_info.get("symbol"),
|
|
684
|
+
"decimals": token_info.get("decimals"),
|
|
685
|
+
"address": token_info.get("address"),
|
|
686
|
+
"chain": token_info.get("chain"),
|
|
687
|
+
}
|
|
688
|
+
gas_token_id = self.gas_token.get("id")
|
|
689
|
+
logger.info(
|
|
690
|
+
f"Current pool set to: {token_info.get('symbol')} on {token_info.get('chain', {}).get('name')}"
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
# Check main wallet USDC balance if depositing main token
|
|
694
|
+
if main_token_amount > 0:
|
|
695
|
+
logger.info("Checking main wallet USDC balance")
|
|
696
|
+
(
|
|
697
|
+
main_usdc_status,
|
|
698
|
+
main_usdc_balance,
|
|
699
|
+
) = await self.balance_adapter.get_balance(
|
|
700
|
+
token_id=token_info.get("token_id"),
|
|
701
|
+
wallet_address=self._get_main_wallet_address(),
|
|
702
|
+
)
|
|
703
|
+
if main_usdc_status and main_usdc_balance is not None:
|
|
704
|
+
try:
|
|
705
|
+
available_main_usdc = float(main_usdc_balance) / (
|
|
706
|
+
10 ** self.current_pool.get("decimals")
|
|
707
|
+
)
|
|
708
|
+
logger.info(f"Main wallet USDC balance: {available_main_usdc}")
|
|
709
|
+
if available_main_usdc >= 0:
|
|
710
|
+
main_token_amount = min(
|
|
711
|
+
main_token_amount, available_main_usdc
|
|
712
|
+
)
|
|
713
|
+
logger.info(
|
|
714
|
+
f"Adjusted deposit amount to available balance: {main_token_amount}"
|
|
715
|
+
)
|
|
716
|
+
except Exception as e:
|
|
717
|
+
logger.warning(f"Error processing main wallet balance: {e}")
|
|
718
|
+
else:
|
|
719
|
+
logger.warning("Could not fetch main wallet USDC balance")
|
|
720
|
+
|
|
721
|
+
if main_token_amount < self.MIN_AMOUNT_USDC:
|
|
722
|
+
logger.warning(
|
|
723
|
+
f"Deposit amount {main_token_amount} below minimum {self.MIN_AMOUNT_USDC}"
|
|
724
|
+
)
|
|
725
|
+
return (
|
|
726
|
+
False,
|
|
727
|
+
f"Minimum deposit is {self.MIN_AMOUNT_USDC} USDC on Base. Received: {main_token_amount}",
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
# Check gas token amount if provided
|
|
731
|
+
if gas_token_amount > 0:
|
|
732
|
+
if gas_token_amount > self.GAS_MAXIMUM:
|
|
733
|
+
return (
|
|
734
|
+
False,
|
|
735
|
+
f"Gas token amount exceeds maximum configured gas buffer: {self.GAS_MAXIMUM}",
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
logger.info("Checking main wallet gas token balance")
|
|
739
|
+
gas_decimals = self.gas_token.get("decimals")
|
|
740
|
+
gas_symbol = self.gas_token.get("symbol")
|
|
741
|
+
(
|
|
742
|
+
_,
|
|
743
|
+
main_gas_raw,
|
|
744
|
+
) = await self.balance_adapter.get_balance(
|
|
745
|
+
token_id=gas_token_id,
|
|
746
|
+
wallet_address=self._get_main_wallet_address(),
|
|
747
|
+
)
|
|
748
|
+
main_gas_int = (
|
|
749
|
+
int(main_gas_raw)
|
|
750
|
+
if isinstance(main_gas_raw, int)
|
|
751
|
+
else int(float(main_gas_raw or 0))
|
|
752
|
+
)
|
|
753
|
+
main_gas_native = float(main_gas_int) / (10**gas_decimals)
|
|
754
|
+
|
|
755
|
+
if main_gas_native < gas_token_amount:
|
|
756
|
+
return (
|
|
757
|
+
False,
|
|
758
|
+
f"Main wallet {gas_symbol} balance is less than the deposit amount: {main_gas_native} < {gas_token_amount}",
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
# Check gas balances for minimum requirement (only if depositing main token)
|
|
762
|
+
if main_token_amount > 0:
|
|
763
|
+
logger.info("Checking gas token balances for operations")
|
|
764
|
+
gas_decimals = self.gas_token.get("decimals")
|
|
765
|
+
gas_symbol = self.gas_token.get("symbol")
|
|
766
|
+
(
|
|
767
|
+
_,
|
|
768
|
+
main_gas_raw,
|
|
769
|
+
) = await self.balance_adapter.get_balance(
|
|
770
|
+
token_id=gas_token_id,
|
|
771
|
+
wallet_address=self._get_main_wallet_address(),
|
|
772
|
+
)
|
|
773
|
+
(
|
|
774
|
+
_,
|
|
775
|
+
strategy_gas_raw,
|
|
776
|
+
) = await self.balance_adapter.get_balance(
|
|
777
|
+
token_id=gas_token_id,
|
|
778
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
779
|
+
)
|
|
780
|
+
main_gas_int = (
|
|
781
|
+
int(main_gas_raw)
|
|
782
|
+
if isinstance(main_gas_raw, int)
|
|
783
|
+
else int(float(main_gas_raw or 0))
|
|
784
|
+
)
|
|
785
|
+
strategy_gas_int = (
|
|
786
|
+
int(strategy_gas_raw)
|
|
787
|
+
if isinstance(strategy_gas_raw, int)
|
|
788
|
+
else int(float(strategy_gas_raw or 0))
|
|
789
|
+
)
|
|
790
|
+
main_gas_native = float(main_gas_int) / (10**gas_decimals)
|
|
791
|
+
strategy_gas_native = float(strategy_gas_int) / (10**gas_decimals)
|
|
792
|
+
total_gas = main_gas_native + strategy_gas_native
|
|
793
|
+
logger.info(
|
|
794
|
+
f"Gas balances - Main: {main_gas_native} {gas_symbol}, Strategy: {strategy_gas_native} {gas_symbol}, Total: {total_gas} {gas_symbol}"
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
# Use provided gas_token_amount if available, otherwise ensure minimum
|
|
798
|
+
required_gas = (
|
|
799
|
+
gas_token_amount if gas_token_amount > 0 else self.MIN_GAS
|
|
800
|
+
)
|
|
801
|
+
if total_gas < required_gas:
|
|
802
|
+
logger.warning(
|
|
803
|
+
f"Insufficient gas: {total_gas} < {required_gas} {gas_symbol}"
|
|
804
|
+
)
|
|
805
|
+
return (
|
|
806
|
+
False,
|
|
807
|
+
f"Need at least {required_gas} {gas_symbol} on Base for gas. You have: {total_gas}",
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# Transfer main token if provided
|
|
811
|
+
if main_token_amount > 0:
|
|
812
|
+
self.current_pool_balance = int(
|
|
813
|
+
main_token_amount * (10 ** self.current_pool.get("decimals"))
|
|
814
|
+
)
|
|
815
|
+
self.DEPOSIT_USDC = main_token_amount
|
|
816
|
+
logger.info(f"Set deposit amount to {main_token_amount} USDC")
|
|
817
|
+
|
|
818
|
+
# Transfer USDC from main to strategy wallet
|
|
819
|
+
logger.info("Initiating USDC transfer from main to strategy wallet")
|
|
820
|
+
(
|
|
821
|
+
success,
|
|
822
|
+
msg,
|
|
823
|
+
) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
|
|
824
|
+
self.usdc_token_info.get("token_id"),
|
|
825
|
+
main_token_amount,
|
|
826
|
+
strategy_name=self.name,
|
|
827
|
+
)
|
|
828
|
+
if not success:
|
|
829
|
+
logger.error(f"USDC transfer failed: {msg}")
|
|
830
|
+
return (False, f"USDC transfer to strategy failed: {msg}")
|
|
831
|
+
logger.info("USDC transfer completed successfully")
|
|
832
|
+
|
|
833
|
+
# Update tracked state
|
|
834
|
+
self._track_token(self.usdc_token_info.get("token_id"))
|
|
835
|
+
self._update_balance(
|
|
836
|
+
self.usdc_token_info.get("token_id"),
|
|
837
|
+
int(main_token_amount * (10 ** self.current_pool.get("decimals"))),
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
# Transfer gas if provided or if strategy needs top-up
|
|
841
|
+
if gas_token_amount > 0:
|
|
842
|
+
# Get gas symbol if not already defined
|
|
843
|
+
if main_token_amount == 0:
|
|
844
|
+
gas_symbol = self.gas_token.get("symbol")
|
|
845
|
+
|
|
846
|
+
# Transfer the specified gas amount
|
|
847
|
+
logger.info(
|
|
848
|
+
f"Transferring {gas_token_amount} {gas_symbol} from main wallet to strategy"
|
|
849
|
+
)
|
|
850
|
+
(
|
|
851
|
+
success,
|
|
852
|
+
msg,
|
|
853
|
+
) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
|
|
854
|
+
gas_token_id, gas_token_amount, strategy_name=self.name
|
|
855
|
+
)
|
|
856
|
+
if not success:
|
|
857
|
+
logger.error(f"Gas transfer failed: {msg}")
|
|
858
|
+
return (False, f"Gas transfer to strategy failed: {msg}")
|
|
859
|
+
logger.info("Gas transfer completed successfully")
|
|
860
|
+
elif main_token_amount > 0 and strategy_gas_native < self.MIN_GAS:
|
|
861
|
+
# Auto-top-up to minimum if no gas amount specified and depositing main token
|
|
862
|
+
top_up_amount = self.MIN_GAS - strategy_gas_native
|
|
863
|
+
logger.info(
|
|
864
|
+
f"Strategy gas insufficient, transferring {top_up_amount} {gas_symbol} from main wallet"
|
|
865
|
+
)
|
|
866
|
+
(
|
|
867
|
+
success,
|
|
868
|
+
msg,
|
|
869
|
+
) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
|
|
870
|
+
gas_token_id, top_up_amount, strategy_name=self.name
|
|
871
|
+
)
|
|
872
|
+
if not success:
|
|
873
|
+
logger.error(f"Gas transfer failed: {msg}")
|
|
874
|
+
return (False, f"Gas transfer to strategy failed: {msg}")
|
|
875
|
+
logger.info("Gas transfer completed successfully")
|
|
876
|
+
|
|
877
|
+
elapsed_time = time.time() - start_time
|
|
878
|
+
logger.info(f"Deposit completed successfully in {elapsed_time:.2f} seconds")
|
|
879
|
+
return (
|
|
880
|
+
True,
|
|
881
|
+
"Deposit successful! Call update to open a position and start earning",
|
|
882
|
+
)
|
|
883
|
+
except Exception as e:
|
|
884
|
+
logger.error(f"Deposit process failed: {e}")
|
|
885
|
+
return (False, f"Deposit error: {e}")
|
|
886
|
+
|
|
887
|
+
async def withdraw(self, amount: float | None = None) -> StatusTuple:
|
|
888
|
+
logger.info(f"Starting withdrawal process for amount: {amount}")
|
|
889
|
+
start_time = time.time()
|
|
890
|
+
|
|
891
|
+
if not self.DEPOSIT_USDC:
|
|
892
|
+
logger.warning("No deposits found, nothing to withdraw")
|
|
893
|
+
return (
|
|
894
|
+
False,
|
|
895
|
+
"Nothing to withdraw from strategy, wallet should be empty already. If not, an error has happened please manually remove funds",
|
|
896
|
+
)
|
|
897
|
+
# Get current pool balance
|
|
898
|
+
logger.info("Fetching current pool balance")
|
|
899
|
+
try:
|
|
900
|
+
(
|
|
901
|
+
_,
|
|
902
|
+
self.current_pool_balance,
|
|
903
|
+
) = await self.balance_adapter.get_pool_balance(
|
|
904
|
+
pool_address=self.current_pool.get("address"),
|
|
905
|
+
chain_id=self.current_pool.get("chain").get("id"),
|
|
906
|
+
user_address=self._get_strategy_wallet_address(),
|
|
907
|
+
)
|
|
908
|
+
logger.info(f"Current pool balance: {self.current_pool_balance}")
|
|
909
|
+
except Exception as e:
|
|
910
|
+
logger.error(f"Failed to fetch pool balance: {e}")
|
|
911
|
+
self.current_pool_balance = 0
|
|
912
|
+
|
|
913
|
+
# Check if we need to swap out of current position
|
|
914
|
+
if (
|
|
915
|
+
self.current_pool.get("token_id") != self.usdc_token_info.get("token_id")
|
|
916
|
+
and self.current_pool_balance
|
|
917
|
+
):
|
|
918
|
+
logger.info(
|
|
919
|
+
f"Need to swap from {self.current_pool.get('symbol')} to USDC before withdrawal"
|
|
920
|
+
)
|
|
921
|
+
quotes = {}
|
|
922
|
+
for attempt in range(4):
|
|
923
|
+
logger.info(
|
|
924
|
+
f"Getting swap quote (attempt {attempt + 1}/4) with slippage: {DEFAULT_SLIPPAGE * (attempt + 1)}"
|
|
925
|
+
)
|
|
926
|
+
try:
|
|
927
|
+
success, quotes = await self.brap_adapter.get_swap_quote(
|
|
928
|
+
from_token_address=self.current_pool.get("address"),
|
|
929
|
+
to_token_address=self.usdc_token_info.get("address"),
|
|
930
|
+
from_chain_id=self.current_pool.get("chain").get("id"),
|
|
931
|
+
to_chain_id=self.usdc_token_info.get("chain").get("id"),
|
|
932
|
+
from_address=self._get_strategy_wallet_address(),
|
|
933
|
+
to_address=self._get_strategy_wallet_address(),
|
|
934
|
+
amount=str(self.current_pool_balance),
|
|
935
|
+
slippage=DEFAULT_SLIPPAGE * (attempt + 1),
|
|
936
|
+
)
|
|
937
|
+
if (
|
|
938
|
+
success
|
|
939
|
+
and quotes.get("quotes")
|
|
940
|
+
and quotes.get("quotes").get("best_quote")
|
|
941
|
+
):
|
|
942
|
+
logger.info("Successfully obtained swap quote")
|
|
943
|
+
break
|
|
944
|
+
except Exception as e:
|
|
945
|
+
logger.warning(f"Quote attempt {attempt + 1} failed: {e}")
|
|
946
|
+
if attempt == 3: # Last attempt
|
|
947
|
+
logger.error("All quote attempts failed")
|
|
948
|
+
|
|
949
|
+
best_quote = quotes.get("quotes").get("best_quote")
|
|
950
|
+
if not best_quote:
|
|
951
|
+
return (
|
|
952
|
+
False,
|
|
953
|
+
"Could not swap tokens out due to market conditions (balances too small to move or slippage required is too high) please manually move funds out",
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
if not best_quote.get("output_amount") or not best_quote.get(
|
|
957
|
+
"input_amount"
|
|
958
|
+
):
|
|
959
|
+
return (False, "Swap quote missing required fields")
|
|
960
|
+
|
|
961
|
+
if not best_quote.get("from_amount_usd"):
|
|
962
|
+
input_amount = int(best_quote.get("input_amount"))
|
|
963
|
+
if self.current_pool.get("token_id") == self.usdc_token_info.get(
|
|
964
|
+
"token_id"
|
|
965
|
+
):
|
|
966
|
+
best_quote["from_amount_usd"] = float(input_amount) / (
|
|
967
|
+
10 ** self.current_pool.get("decimals")
|
|
968
|
+
)
|
|
969
|
+
else:
|
|
970
|
+
best_quote["from_amount_usd"] = await self._get_pool_usd_value(
|
|
971
|
+
self.current_pool, input_amount
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
if not best_quote.get("to_amount_usd"):
|
|
975
|
+
output_amount = int(best_quote.get("output_amount"))
|
|
976
|
+
best_quote["to_amount_usd"] = float(
|
|
977
|
+
output_amount
|
|
978
|
+
) / 10 ** self.usdc_token_info.get("decimals")
|
|
979
|
+
|
|
980
|
+
if not self.brap_adapter:
|
|
981
|
+
return (
|
|
982
|
+
False,
|
|
983
|
+
"BRAP adapter not initialized; cannot unwind position.",
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
success, swap_result = await self.brap_adapter.swap_from_quote(
|
|
987
|
+
self.current_pool,
|
|
988
|
+
self.usdc_token_info,
|
|
989
|
+
self._get_strategy_wallet_address(),
|
|
990
|
+
best_quote,
|
|
991
|
+
strategy_name=self.name,
|
|
992
|
+
)
|
|
993
|
+
if not success:
|
|
994
|
+
return (
|
|
995
|
+
False,
|
|
996
|
+
f"Failed to unwind position via swap: {swap_result}",
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
await self._sweep_wallet(self.usdc_token_info)
|
|
1000
|
+
withdrawn_breakdown = []
|
|
1001
|
+
withdrawn_token_ids = set()
|
|
1002
|
+
|
|
1003
|
+
if self.usdc_token_info.get("token_id") in withdrawn_token_ids:
|
|
1004
|
+
pass
|
|
1005
|
+
status, raw_balance = await self.balance_adapter.get_balance(
|
|
1006
|
+
token_id=self.usdc_token_info.get("token_id"),
|
|
1007
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
1008
|
+
)
|
|
1009
|
+
if not status or not raw_balance:
|
|
1010
|
+
pass
|
|
1011
|
+
amount = float(raw_balance) / 10 ** self.usdc_token_info.get("decimals")
|
|
1012
|
+
if amount > 0:
|
|
1013
|
+
(
|
|
1014
|
+
move_status,
|
|
1015
|
+
move_message,
|
|
1016
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
1017
|
+
self.usdc_token_info.get("token_id"),
|
|
1018
|
+
amount,
|
|
1019
|
+
strategy_name=self.name,
|
|
1020
|
+
)
|
|
1021
|
+
if not move_status:
|
|
1022
|
+
return (False, f"USDC return to main failed: {move_message}")
|
|
1023
|
+
|
|
1024
|
+
withdrawn_breakdown.append(
|
|
1025
|
+
(
|
|
1026
|
+
self.usdc_token_info.get("symbol"),
|
|
1027
|
+
self.usdc_token_info.get("chain").get("name"),
|
|
1028
|
+
float(amount),
|
|
1029
|
+
)
|
|
1030
|
+
)
|
|
1031
|
+
withdrawn_token_ids.add(self.usdc_token_info.get("token_id"))
|
|
1032
|
+
|
|
1033
|
+
if self.gas_token and self.gas_token.get("id") not in withdrawn_token_ids:
|
|
1034
|
+
status, raw_gas = await self.balance_adapter.get_balance(
|
|
1035
|
+
token_id=self.gas_token.get("id"),
|
|
1036
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
1037
|
+
)
|
|
1038
|
+
if status and raw_gas:
|
|
1039
|
+
gas_amount = (
|
|
1040
|
+
float(raw_gas) / 10 ** self.gas_token.get("decimals")
|
|
1041
|
+
) * 0.9
|
|
1042
|
+
if gas_amount > 0:
|
|
1043
|
+
(
|
|
1044
|
+
move_gas_status,
|
|
1045
|
+
move_gas_message,
|
|
1046
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
1047
|
+
self.gas_token.get("id"),
|
|
1048
|
+
gas_amount,
|
|
1049
|
+
strategy_name=self.name,
|
|
1050
|
+
)
|
|
1051
|
+
if move_gas_status:
|
|
1052
|
+
withdrawn_breakdown.append(
|
|
1053
|
+
(
|
|
1054
|
+
self.gas_token.get("symbol"),
|
|
1055
|
+
self.gas_token.get("chain").get("name"),
|
|
1056
|
+
float(gas_amount),
|
|
1057
|
+
)
|
|
1058
|
+
)
|
|
1059
|
+
withdrawn_token_ids.add(self.gas_token.get("id"))
|
|
1060
|
+
|
|
1061
|
+
self.DEPOSIT_USDC = 0
|
|
1062
|
+
self.current_pool_balance = 0
|
|
1063
|
+
|
|
1064
|
+
if not withdrawn_breakdown:
|
|
1065
|
+
return (True, f"Successfully withdrew {amount} USDC from strategy")
|
|
1066
|
+
|
|
1067
|
+
breakdown_msg = ", ".join(
|
|
1068
|
+
f"{amount:.6f} {symbol} on {chain.capitalize()}"
|
|
1069
|
+
for symbol, chain, amount in withdrawn_breakdown
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
elapsed_time = time.time() - start_time
|
|
1073
|
+
logger.info(f"Withdrawal completed successfully in {elapsed_time:.2f} seconds")
|
|
1074
|
+
return (
|
|
1075
|
+
True,
|
|
1076
|
+
f"Successfully withdrew {amount} USDC from strategy: {breakdown_msg}",
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
async def _get_last_rotation_time(self, wallet_address: str) -> datetime | None:
|
|
1080
|
+
success, data = await self.ledger_adapter.get_strategy_latest_transactions(
|
|
1081
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
1082
|
+
)
|
|
1083
|
+
if success is False:
|
|
1084
|
+
return None
|
|
1085
|
+
for transaction in data.get("transactions", []):
|
|
1086
|
+
op_data = transaction.get("op_data", {})
|
|
1087
|
+
if op_data.get("type") == "SWAP" and op_data.get(
|
|
1088
|
+
"to_token_id"
|
|
1089
|
+
).lower() not in [
|
|
1090
|
+
"usd-coin-base",
|
|
1091
|
+
"base_0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
1092
|
+
]:
|
|
1093
|
+
created_str = transaction.get("created")
|
|
1094
|
+
if not created_str:
|
|
1095
|
+
continue
|
|
1096
|
+
try:
|
|
1097
|
+
dt = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
|
|
1098
|
+
if dt.tzinfo is None:
|
|
1099
|
+
dt = dt.replace(tzinfo=UTC)
|
|
1100
|
+
return dt
|
|
1101
|
+
except (ValueError, AttributeError):
|
|
1102
|
+
continue
|
|
1103
|
+
return None
|
|
1104
|
+
|
|
1105
|
+
async def update(self):
|
|
1106
|
+
logger.info("Starting strategy update process")
|
|
1107
|
+
start_time = time.time()
|
|
1108
|
+
|
|
1109
|
+
if not self.DEPOSIT_USDC:
|
|
1110
|
+
logger.warning("No deposits found, cannot update strategy")
|
|
1111
|
+
return [False, "Nothing has been deposited in this strategy, cannot update"]
|
|
1112
|
+
|
|
1113
|
+
logger.info("Getting non-gas balances")
|
|
1114
|
+
non_gas_balances = await self._get_non_gas_balances()
|
|
1115
|
+
current_target = self.current_pool
|
|
1116
|
+
if current_target is None:
|
|
1117
|
+
current_target = self.usdc_token_info
|
|
1118
|
+
logger.info("No current pool set, using USDC as target")
|
|
1119
|
+
|
|
1120
|
+
logger.info("Searching for best yield opportunities")
|
|
1121
|
+
should_deposit, pool_data = await self._find_best_pool()
|
|
1122
|
+
if not should_deposit:
|
|
1123
|
+
if (
|
|
1124
|
+
current_target
|
|
1125
|
+
and isinstance(current_target, dict)
|
|
1126
|
+
and await self._has_idle_assets(non_gas_balances, current_target)
|
|
1127
|
+
):
|
|
1128
|
+
await self._sweep_wallet(current_target)
|
|
1129
|
+
await self._refresh_current_pool_balance()
|
|
1130
|
+
return (
|
|
1131
|
+
True,
|
|
1132
|
+
f"Consolidated assets into existing position {current_target.get('id')}",
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
if isinstance(pool_data, dict):
|
|
1136
|
+
message = pool_data.get(
|
|
1137
|
+
"message", "No profitable pools found, staying in current pool"
|
|
1138
|
+
)
|
|
1139
|
+
else:
|
|
1140
|
+
message = (
|
|
1141
|
+
str(pool_data)
|
|
1142
|
+
if pool_data
|
|
1143
|
+
else "No profitable pools found, staying in current pool"
|
|
1144
|
+
)
|
|
1145
|
+
return False, message
|
|
1146
|
+
|
|
1147
|
+
if not isinstance(pool_data, dict):
|
|
1148
|
+
return [False, f"Invalid pool data format: {type(pool_data).__name__}"]
|
|
1149
|
+
|
|
1150
|
+
target_pool = pool_data.get("target_pool")
|
|
1151
|
+
target_pool_data = pool_data.get("target_pool_data")
|
|
1152
|
+
brap_quote = pool_data.get("brap_quote")
|
|
1153
|
+
|
|
1154
|
+
if not target_pool or not target_pool_data or not brap_quote:
|
|
1155
|
+
return [False, "Missing required pool data for rebalancing"]
|
|
1156
|
+
|
|
1157
|
+
gas_status, gas_message = await self._rebalance_gas(target_pool)
|
|
1158
|
+
if not gas_status:
|
|
1159
|
+
return [False, gas_message]
|
|
1160
|
+
|
|
1161
|
+
previous_pool = self.current_pool
|
|
1162
|
+
|
|
1163
|
+
last_rotation = await self._get_last_rotation_time(
|
|
1164
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
1165
|
+
)
|
|
1166
|
+
if (
|
|
1167
|
+
previous_pool
|
|
1168
|
+
and isinstance(previous_pool, dict)
|
|
1169
|
+
and previous_pool.get("token_id") != self.usdc_token_info.get("token_id")
|
|
1170
|
+
and last_rotation is not None
|
|
1171
|
+
):
|
|
1172
|
+
now = datetime.now(UTC)
|
|
1173
|
+
if (now - last_rotation) < self.ROTATION_MIN_INTERVAL:
|
|
1174
|
+
elapsed = now - last_rotation
|
|
1175
|
+
remaining = self.ROTATION_MIN_INTERVAL - elapsed
|
|
1176
|
+
remaining_days_cooldown = max(0.0, remaining.total_seconds() / 86400)
|
|
1177
|
+
cooldown_notice = (
|
|
1178
|
+
"Within 7-day cooldown; existing {coin} position retained. "
|
|
1179
|
+
"≈{days:.1f} days until rotation window reopens."
|
|
1180
|
+
).format(
|
|
1181
|
+
coin=(
|
|
1182
|
+
previous_pool.get("token_id", "unknown")
|
|
1183
|
+
if isinstance(previous_pool, dict)
|
|
1184
|
+
else "unknown"
|
|
1185
|
+
),
|
|
1186
|
+
days=remaining_days_cooldown,
|
|
1187
|
+
)
|
|
1188
|
+
return (True, cooldown_notice, False)
|
|
1189
|
+
|
|
1190
|
+
await self.brap_adapter.swap_from_quote(
|
|
1191
|
+
previous_pool,
|
|
1192
|
+
target_pool,
|
|
1193
|
+
self._get_strategy_wallet_address(),
|
|
1194
|
+
brap_quote,
|
|
1195
|
+
strategy_name=self.name,
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
# Track the new target pool token
|
|
1199
|
+
if target_pool and target_pool.get("token_id"):
|
|
1200
|
+
self._track_token(target_pool.get("token_id"))
|
|
1201
|
+
|
|
1202
|
+
self.current_pool = target_pool
|
|
1203
|
+
if self.current_pool and self.current_pool.get("token_id"):
|
|
1204
|
+
success, pool_reports = await self.pool_adapter.get_pools_by_ids(
|
|
1205
|
+
pool_ids=[self.current_pool.get("token_id")]
|
|
1206
|
+
)
|
|
1207
|
+
if success and pool_reports.get("pools"):
|
|
1208
|
+
self.current_pool_data = pool_reports.get("pools", [])[0]
|
|
1209
|
+
else:
|
|
1210
|
+
self.current_pool_data = None
|
|
1211
|
+
else:
|
|
1212
|
+
self.current_pool_data = None
|
|
1213
|
+
if self.current_pool_data:
|
|
1214
|
+
self.current_combined_apy_pct = self.current_pool_data.get("apy", 0)
|
|
1215
|
+
else:
|
|
1216
|
+
self.current_combined_apy_pct = (
|
|
1217
|
+
target_pool_data.get("llama_combined_apy_pct", 0) / 100
|
|
1218
|
+
if target_pool_data
|
|
1219
|
+
else 0
|
|
1220
|
+
)
|
|
1221
|
+
output_amount = (
|
|
1222
|
+
brap_quote.get("output_amount")
|
|
1223
|
+
if brap_quote and isinstance(brap_quote, dict)
|
|
1224
|
+
else None
|
|
1225
|
+
)
|
|
1226
|
+
self.current_pool_balance = (
|
|
1227
|
+
int(output_amount) if output_amount is not None else 0
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
await asyncio.sleep(2)
|
|
1231
|
+
await self._sweep_wallet(target_pool)
|
|
1232
|
+
await self._refresh_current_pool_balance()
|
|
1233
|
+
|
|
1234
|
+
elapsed_time = time.time() - start_time
|
|
1235
|
+
logger.info(
|
|
1236
|
+
f"Strategy update completed successfully in {elapsed_time:.2f} seconds"
|
|
1237
|
+
)
|
|
1238
|
+
return [True, "Updated successfully"]
|
|
1239
|
+
|
|
1240
|
+
async def _refresh_current_pool_balance(self):
|
|
1241
|
+
pool = self.current_pool
|
|
1242
|
+
if not pool or pool.get("chain") is None:
|
|
1243
|
+
return
|
|
1244
|
+
|
|
1245
|
+
strategy_address = self._get_strategy_wallet_address()
|
|
1246
|
+
try:
|
|
1247
|
+
(
|
|
1248
|
+
_,
|
|
1249
|
+
refreshed_pool_balance,
|
|
1250
|
+
) = await self.balance_adapter.get_pool_balance(
|
|
1251
|
+
pool_address=pool.get("address"),
|
|
1252
|
+
chain_id=pool.get("chain").get("id"),
|
|
1253
|
+
user_address=strategy_address,
|
|
1254
|
+
)
|
|
1255
|
+
self.current_pool_balance = int(refreshed_pool_balance)
|
|
1256
|
+
except Exception:
|
|
1257
|
+
pass
|
|
1258
|
+
|
|
1259
|
+
async def _sweep_wallet(self, target_token):
|
|
1260
|
+
"""Sweep all tracked non-target tokens into the target token."""
|
|
1261
|
+
# Refresh tracked balances
|
|
1262
|
+
await self._refresh_tracked_balances()
|
|
1263
|
+
|
|
1264
|
+
target_token_id = target_token.get("token_id")
|
|
1265
|
+
target_chain = target_token.get("chain").get("code", "").lower()
|
|
1266
|
+
target_address = target_token.get("address", "").lower()
|
|
1267
|
+
gas_token_id = self.gas_token.get("id") if self.gas_token else None
|
|
1268
|
+
|
|
1269
|
+
# Swap all non-target, non-gas tokens to the target
|
|
1270
|
+
for token_id, balance_wei in list(self.tracked_balances.items()):
|
|
1271
|
+
# Skip if no balance
|
|
1272
|
+
if balance_wei <= 0:
|
|
1273
|
+
continue
|
|
1274
|
+
|
|
1275
|
+
# Skip gas token
|
|
1276
|
+
if token_id == gas_token_id:
|
|
1277
|
+
continue
|
|
1278
|
+
|
|
1279
|
+
# Skip if it's already the target token
|
|
1280
|
+
if token_id == target_token_id:
|
|
1281
|
+
continue
|
|
1282
|
+
|
|
1283
|
+
# Get fresh balance to ensure accuracy
|
|
1284
|
+
try:
|
|
1285
|
+
success, fresh_balance = await self.balance_adapter.get_balance(
|
|
1286
|
+
token_id=token_id,
|
|
1287
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
1288
|
+
)
|
|
1289
|
+
if not success or not fresh_balance or int(fresh_balance) <= 0:
|
|
1290
|
+
self._update_balance(token_id, 0)
|
|
1291
|
+
continue
|
|
1292
|
+
|
|
1293
|
+
balance_wei = int(fresh_balance)
|
|
1294
|
+
except Exception:
|
|
1295
|
+
continue
|
|
1296
|
+
|
|
1297
|
+
# Construct target token ID for swap
|
|
1298
|
+
target_token_id_for_swap = f"{target_chain}_{target_address}"
|
|
1299
|
+
|
|
1300
|
+
try:
|
|
1301
|
+
logger.info(
|
|
1302
|
+
f"Sweeping {token_id} (balance: {balance_wei}) to {target_token_id}"
|
|
1303
|
+
)
|
|
1304
|
+
success, msg = await self.brap_adapter.swap_from_token_ids(
|
|
1305
|
+
token_id,
|
|
1306
|
+
target_token_id_for_swap,
|
|
1307
|
+
self._get_strategy_wallet_address(),
|
|
1308
|
+
str(balance_wei),
|
|
1309
|
+
strategy_name=self.name,
|
|
1310
|
+
)
|
|
1311
|
+
if success:
|
|
1312
|
+
# Update tracked state: source token now has 0 balance
|
|
1313
|
+
self._update_balance(token_id, 0)
|
|
1314
|
+
logger.info(f"Successfully swept {token_id} to {target_token_id}")
|
|
1315
|
+
else:
|
|
1316
|
+
logger.warning(f"Failed to sweep {token_id}: {msg}")
|
|
1317
|
+
except Exception as e:
|
|
1318
|
+
logger.error(f"Error sweeping {token_id}: {e}")
|
|
1319
|
+
continue
|
|
1320
|
+
|
|
1321
|
+
# Track the target token
|
|
1322
|
+
self._track_token(target_token_id)
|
|
1323
|
+
# Refresh target token balance
|
|
1324
|
+
try:
|
|
1325
|
+
success, target_balance = await self.balance_adapter.get_balance(
|
|
1326
|
+
token_id=target_token_id,
|
|
1327
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
1328
|
+
)
|
|
1329
|
+
if success and target_balance:
|
|
1330
|
+
self._update_balance(target_token_id, int(target_balance))
|
|
1331
|
+
except Exception:
|
|
1332
|
+
pass
|
|
1333
|
+
|
|
1334
|
+
async def _rebalance_gas(self, target_pool) -> tuple[bool, str]:
|
|
1335
|
+
if self.gas_token.get("chain").get("id") != target_pool.get("chain").get("id"):
|
|
1336
|
+
return False, "Unsupported chain for gas management."
|
|
1337
|
+
|
|
1338
|
+
# TODO: do we need to categorize strategy wallet addresses?
|
|
1339
|
+
strategy_address = self._get_strategy_wallet_address()
|
|
1340
|
+
|
|
1341
|
+
required_gas = int(self.MIN_GAS * (10 ** self.gas_token.get("decimals")))
|
|
1342
|
+
_, current_gas = await self.balance_adapter.get_balance(
|
|
1343
|
+
token_id=self.gas_token.get("id"),
|
|
1344
|
+
wallet_address=strategy_address,
|
|
1345
|
+
)
|
|
1346
|
+
if current_gas >= required_gas:
|
|
1347
|
+
return True, "Enough gas balance found."
|
|
1348
|
+
|
|
1349
|
+
current_native = float(current_gas) / 10 ** self.gas_token.get("decimals")
|
|
1350
|
+
shortfall = max(self.MIN_GAS - current_native, 0)
|
|
1351
|
+
|
|
1352
|
+
return (
|
|
1353
|
+
False,
|
|
1354
|
+
f"Strategy wallet does not have enough gas. Shortfall: {shortfall} {self.gas_token.get('symbol')}",
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
async def _has_idle_assets(self, balances, target_token) -> bool:
|
|
1358
|
+
for balance in balances:
|
|
1359
|
+
if self._balance_matches_token(balance, target_token):
|
|
1360
|
+
continue
|
|
1361
|
+
amount = balance.get("_amount_wei")
|
|
1362
|
+
if isinstance(amount, int) and amount > 0:
|
|
1363
|
+
return True
|
|
1364
|
+
return False
|
|
1365
|
+
|
|
1366
|
+
def _balance_matches_token(self, balance, token) -> bool:
|
|
1367
|
+
token_id = balance.get("token_id")
|
|
1368
|
+
if (
|
|
1369
|
+
isinstance(token_id, str)
|
|
1370
|
+
and token_id.lower() == token.get("token_id").lower()
|
|
1371
|
+
):
|
|
1372
|
+
return True
|
|
1373
|
+
|
|
1374
|
+
token_address = balance.get("tokenAddress")
|
|
1375
|
+
if not isinstance(token_address, str):
|
|
1376
|
+
return False
|
|
1377
|
+
|
|
1378
|
+
network = (balance.get("network") or "").lower()
|
|
1379
|
+
chain_names = {
|
|
1380
|
+
getattr(token.get("chain"), "name", "").lower(),
|
|
1381
|
+
getattr(token.get("chain"), "code", "").lower(),
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
return network in chain_names and token_address.lower() == token.address.lower()
|
|
1385
|
+
|
|
1386
|
+
async def _get_pool_usd_value(self, token, amount):
|
|
1387
|
+
chain_id = token.get("chain").get("id")
|
|
1388
|
+
if chain_id != self.usdc_token_info.get("chain").get("id"):
|
|
1389
|
+
return 0.0
|
|
1390
|
+
|
|
1391
|
+
success, exit_quotes = await self.brap_adapter.get_swap_quote(
|
|
1392
|
+
from_token_address=token.get("address"),
|
|
1393
|
+
to_token_address=self.usdc_token_info.get("address"),
|
|
1394
|
+
from_chain_id=chain_id,
|
|
1395
|
+
to_chain_id=self.usdc_token_info.get("chain").get("id"),
|
|
1396
|
+
from_address=self._get_strategy_wallet_address(),
|
|
1397
|
+
to_address=self._get_strategy_wallet_address(),
|
|
1398
|
+
amount=str(amount),
|
|
1399
|
+
)
|
|
1400
|
+
if not success:
|
|
1401
|
+
return 0.0
|
|
1402
|
+
|
|
1403
|
+
best_quote = exit_quotes.get("quotes").get("best_quote")
|
|
1404
|
+
current_pool_usd_value = best_quote.get("output_amount")
|
|
1405
|
+
|
|
1406
|
+
return float(
|
|
1407
|
+
float(current_pool_usd_value) / (10 ** self.usdc_token_info.get("decimals"))
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
async def _get_non_gas_balances(self) -> list[dict[str, Any]]:
|
|
1411
|
+
"""Get non-gas balances from tracked tokens."""
|
|
1412
|
+
# Refresh tracked balances
|
|
1413
|
+
await self._refresh_tracked_balances()
|
|
1414
|
+
|
|
1415
|
+
gas_token_id = self.gas_token.get("id") if self.gas_token else None
|
|
1416
|
+
results = []
|
|
1417
|
+
|
|
1418
|
+
for token_id, balance_wei in self.tracked_balances.items():
|
|
1419
|
+
# Skip gas token
|
|
1420
|
+
if token_id == gas_token_id:
|
|
1421
|
+
continue
|
|
1422
|
+
|
|
1423
|
+
# Skip zero balances
|
|
1424
|
+
if balance_wei <= 0:
|
|
1425
|
+
continue
|
|
1426
|
+
|
|
1427
|
+
# Fetch token info to get address and chain
|
|
1428
|
+
try:
|
|
1429
|
+
success, token_info = await self.token_adapter.get_token(token_id)
|
|
1430
|
+
if not success or not token_info:
|
|
1431
|
+
continue
|
|
1432
|
+
|
|
1433
|
+
results.append(
|
|
1434
|
+
{
|
|
1435
|
+
"token_id": token_id,
|
|
1436
|
+
"tokenAddress": token_info.get("address"),
|
|
1437
|
+
"network": token_info.get("chain", {}).get("code", "").upper(),
|
|
1438
|
+
"_amount_wei": balance_wei,
|
|
1439
|
+
}
|
|
1440
|
+
)
|
|
1441
|
+
except Exception as e:
|
|
1442
|
+
logger.warning(f"Failed to get token info for {token_id}: {e}")
|
|
1443
|
+
continue
|
|
1444
|
+
|
|
1445
|
+
return results
|
|
1446
|
+
|
|
1447
|
+
async def _find_best_pool(self) -> tuple[bool, dict[str, Any]]:
|
|
1448
|
+
success, llama_data = await self.pool_adapter.get_llama_matches()
|
|
1449
|
+
if not success:
|
|
1450
|
+
return False, {"message": f"Failed to fetch Llama data: {llama_data}"}
|
|
1451
|
+
|
|
1452
|
+
llama_pools = [
|
|
1453
|
+
pool
|
|
1454
|
+
for pool in llama_data.get("matches", [])
|
|
1455
|
+
if pool.get("llama_stablecoin")
|
|
1456
|
+
and pool.get("llama_il_risk") == "no"
|
|
1457
|
+
and pool.get("llama_tvl_usd") > self.MIN_TVL
|
|
1458
|
+
and pool.get("llama_apy_pct") > self.DUST_APY
|
|
1459
|
+
and pool.get("network", "").lower() in self.SUPPORTED_NETWORK_CODES
|
|
1460
|
+
]
|
|
1461
|
+
llama_pools = sorted(
|
|
1462
|
+
llama_pools, key=lambda pool: pool.get("llama_apy_pct"), reverse=True
|
|
1463
|
+
)
|
|
1464
|
+
if not llama_pools:
|
|
1465
|
+
return False, {"message": "No suitable pools found."}
|
|
1466
|
+
|
|
1467
|
+
for candidate in llama_pools[: self.SEARCH_DEPTH]:
|
|
1468
|
+
if candidate.get("address") == self.current_pool.get("address"):
|
|
1469
|
+
return False, {"message": "Already in the best pool, no action needed."}
|
|
1470
|
+
|
|
1471
|
+
try:
|
|
1472
|
+
target_status, target_pool = await self.token_adapter.get_token(
|
|
1473
|
+
address=candidate.get("address")
|
|
1474
|
+
)
|
|
1475
|
+
if not target_status and candidate.get("token_id"):
|
|
1476
|
+
target_status, target_pool = await self.token_adapter.get_token(
|
|
1477
|
+
token_id=candidate.get("token_id")
|
|
1478
|
+
)
|
|
1479
|
+
if not target_status and candidate.get("pool_id"):
|
|
1480
|
+
target_status, target_pool = await self.token_adapter.get_token(
|
|
1481
|
+
token_id=candidate.get("pool_id")
|
|
1482
|
+
)
|
|
1483
|
+
if not target_status:
|
|
1484
|
+
continue
|
|
1485
|
+
except Exception:
|
|
1486
|
+
continue
|
|
1487
|
+
|
|
1488
|
+
brap_quote = await self._search(
|
|
1489
|
+
candidate,
|
|
1490
|
+
self.current_pool,
|
|
1491
|
+
target_pool,
|
|
1492
|
+
self.current_combined_apy_pct,
|
|
1493
|
+
int(
|
|
1494
|
+
self.current_pool_balance
|
|
1495
|
+
* (10 ** self.current_pool.get("decimals"))
|
|
1496
|
+
),
|
|
1497
|
+
)
|
|
1498
|
+
if brap_quote:
|
|
1499
|
+
return True, {
|
|
1500
|
+
"target_pool": target_pool,
|
|
1501
|
+
"target_pool_data": candidate,
|
|
1502
|
+
"brap_quote": brap_quote,
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
return False, {"message": "No suitable pools found after searching."}
|
|
1506
|
+
|
|
1507
|
+
async def _search(
|
|
1508
|
+
self,
|
|
1509
|
+
pool_data,
|
|
1510
|
+
current_token,
|
|
1511
|
+
token,
|
|
1512
|
+
current_combined_apy_pct,
|
|
1513
|
+
current_token_balance,
|
|
1514
|
+
):
|
|
1515
|
+
if token is None or current_token is None:
|
|
1516
|
+
return None
|
|
1517
|
+
if token is None or token.get("chain") is None:
|
|
1518
|
+
return None
|
|
1519
|
+
if current_token is None or current_token.get("chain") is None:
|
|
1520
|
+
return None
|
|
1521
|
+
|
|
1522
|
+
try:
|
|
1523
|
+
combined_apy_pct = pool_data.get("llama_combined_apy_pct") / 100
|
|
1524
|
+
success, quotes = await self.brap_adapter.get_swap_quote(
|
|
1525
|
+
from_token_address=current_token.get("address"),
|
|
1526
|
+
to_token_address=token.get("address"),
|
|
1527
|
+
from_chain_id=current_token.get("chain").get("id"),
|
|
1528
|
+
to_chain_id=token.get("chain").get("id"),
|
|
1529
|
+
from_address=self._get_strategy_wallet_address(),
|
|
1530
|
+
to_address=self._get_strategy_wallet_address(),
|
|
1531
|
+
amount=str(current_token_balance),
|
|
1532
|
+
)
|
|
1533
|
+
if not success:
|
|
1534
|
+
return None
|
|
1535
|
+
quotes_data = quotes.get("quotes") if isinstance(quotes, dict) else None
|
|
1536
|
+
if not isinstance(quotes_data, dict):
|
|
1537
|
+
return None
|
|
1538
|
+
best_quote = quotes_data.get("best_quote")
|
|
1539
|
+
if not best_quote:
|
|
1540
|
+
return None
|
|
1541
|
+
|
|
1542
|
+
target_pool_usd_val = await self._get_pool_usd_value(
|
|
1543
|
+
token, best_quote.get("output_amount")
|
|
1544
|
+
)
|
|
1545
|
+
|
|
1546
|
+
if current_token.get("token_id") != self.usdc_token_info.get("token_id"):
|
|
1547
|
+
current_pool_usd_val = await self._get_pool_usd_value(
|
|
1548
|
+
current_token, best_quote.get("input_amount")
|
|
1549
|
+
)
|
|
1550
|
+
else:
|
|
1551
|
+
current_pool_usd_val = float(
|
|
1552
|
+
float(self.current_pool_balance)
|
|
1553
|
+
/ (10 ** current_token.get("decimals"))
|
|
1554
|
+
)
|
|
1555
|
+
|
|
1556
|
+
gas_cost = await self._get_gas_value(best_quote.get("input_amount"))
|
|
1557
|
+
fee_cost = (current_pool_usd_val - target_pool_usd_val) + gas_cost
|
|
1558
|
+
delta_combined_apy_pct = combined_apy_pct - current_combined_apy_pct
|
|
1559
|
+
|
|
1560
|
+
if delta_combined_apy_pct < self.MINIMUM_APY_IMPROVEMENT:
|
|
1561
|
+
return None
|
|
1562
|
+
|
|
1563
|
+
estimated_profit = (
|
|
1564
|
+
self.MINIMUM_DAYS_UNTIL_PROFIT
|
|
1565
|
+
* ((delta_combined_apy_pct * current_pool_usd_val) / 365)
|
|
1566
|
+
- fee_cost
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
if estimated_profit > 0:
|
|
1570
|
+
best_quote["from_amount_usd"] = current_pool_usd_val
|
|
1571
|
+
best_quote["to_amount_usd"] = target_pool_usd_val
|
|
1572
|
+
return best_quote
|
|
1573
|
+
|
|
1574
|
+
except Exception:
|
|
1575
|
+
return {}
|
|
1576
|
+
|
|
1577
|
+
async def _get_gas_value(self, amount):
|
|
1578
|
+
token = self.gas_token
|
|
1579
|
+
success, gas_price_data = await self.token_adapter.get_token_price(
|
|
1580
|
+
token.get("token_id")
|
|
1581
|
+
)
|
|
1582
|
+
if not success:
|
|
1583
|
+
return 0.0
|
|
1584
|
+
gas_price = gas_price_data.get("current_price", 0.0)
|
|
1585
|
+
return float(gas_price) * float(amount) / (10 ** token.get("decimals"))
|
|
1586
|
+
|
|
1587
|
+
async def _status(self) -> StatusDict:
|
|
1588
|
+
# Get ETH gas balance
|
|
1589
|
+
gas_success, gas_balance_wei = await self.balance_adapter.get_balance(
|
|
1590
|
+
token_id=self.gas_token.get("id"),
|
|
1591
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
1592
|
+
)
|
|
1593
|
+
gas_balance = (
|
|
1594
|
+
float(gas_balance_wei) / (10 ** self.gas_token.get("decimals"))
|
|
1595
|
+
if gas_success
|
|
1596
|
+
else 0.0
|
|
1597
|
+
)
|
|
1598
|
+
|
|
1599
|
+
if not self.DEPOSIT_USDC:
|
|
1600
|
+
# No deposits recorded - report minimal status
|
|
1601
|
+
status_payload = {
|
|
1602
|
+
"info": "No recorded strategy deposits.",
|
|
1603
|
+
"idle_usd": 0.0,
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
return StatusDict(
|
|
1607
|
+
portfolio_value=0.0,
|
|
1608
|
+
net_deposit=0,
|
|
1609
|
+
strategy_status=status_payload,
|
|
1610
|
+
gas_available=gas_balance,
|
|
1611
|
+
gassed_up=gas_balance >= self.GAS_MAXIMUM * self.GAS_SAFETY_FRACTION,
|
|
1612
|
+
)
|
|
1613
|
+
|
|
1614
|
+
# Refresh tracked balances
|
|
1615
|
+
await self._refresh_tracked_balances()
|
|
1616
|
+
|
|
1617
|
+
# Calculate total value from tracked non-gas balances
|
|
1618
|
+
total_value = 0.0
|
|
1619
|
+
gas_token_id = self.gas_token.get("id") if self.gas_token else None
|
|
1620
|
+
|
|
1621
|
+
for token_id, balance_wei in self.tracked_balances.items():
|
|
1622
|
+
if token_id == gas_token_id:
|
|
1623
|
+
continue
|
|
1624
|
+
if balance_wei <= 0:
|
|
1625
|
+
continue
|
|
1626
|
+
|
|
1627
|
+
try:
|
|
1628
|
+
# Get token price to calculate USD value
|
|
1629
|
+
success, price_data = await self.token_adapter.get_token_price(token_id)
|
|
1630
|
+
if not success:
|
|
1631
|
+
continue
|
|
1632
|
+
|
|
1633
|
+
success, token_info = await self.token_adapter.get_token(token_id)
|
|
1634
|
+
if not success:
|
|
1635
|
+
continue
|
|
1636
|
+
|
|
1637
|
+
decimals = token_info.get("decimals", 18)
|
|
1638
|
+
price = price_data.get("current_price", 0.0)
|
|
1639
|
+
balance_usd = (float(balance_wei) / (10**decimals)) * price
|
|
1640
|
+
total_value += balance_usd
|
|
1641
|
+
except Exception as e:
|
|
1642
|
+
logger.warning(f"Failed to calculate value for {token_id}: {e}")
|
|
1643
|
+
continue
|
|
1644
|
+
|
|
1645
|
+
status_payload = (
|
|
1646
|
+
{
|
|
1647
|
+
"current_pool": self.current_pool.get("token_id"),
|
|
1648
|
+
"carrying_loss": None,
|
|
1649
|
+
"pool_balance": self.current_pool_balance
|
|
1650
|
+
/ (10 ** self.current_pool.get("decimals")),
|
|
1651
|
+
"pool_apy": f"{self.current_combined_apy_pct * 100}%",
|
|
1652
|
+
"pool_tvl": (
|
|
1653
|
+
self.current_pool_data.get("tvl")
|
|
1654
|
+
if self.current_pool_data
|
|
1655
|
+
else None
|
|
1656
|
+
),
|
|
1657
|
+
}
|
|
1658
|
+
if self.current_pool
|
|
1659
|
+
else {}
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
return StatusDict(
|
|
1663
|
+
portfolio_value=total_value,
|
|
1664
|
+
net_deposit=self.DEPOSIT_USDC,
|
|
1665
|
+
strategy_status=status_payload,
|
|
1666
|
+
gas_available=gas_balance,
|
|
1667
|
+
gassed_up=gas_balance >= self.GAS_MAXIMUM * self.GAS_SAFETY_FRACTION,
|
|
1668
|
+
)
|
|
1669
|
+
|
|
1670
|
+
@staticmethod
|
|
1671
|
+
def policies() -> list[str]:
|
|
1672
|
+
enso_router = "0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf".lower()
|
|
1673
|
+
approve_enso = (
|
|
1674
|
+
"eth.tx.data[0..10] == '0x095ea7b3' && "
|
|
1675
|
+
f"eth.tx.data[34..74] == '{enso_router[2:]}'"
|
|
1676
|
+
)
|
|
1677
|
+
swap_enso = f"eth.tx.to == '{enso_router}'"
|
|
1678
|
+
wallet_id = "wallet.id == 'FORMAT_WALLET_ID'"
|
|
1679
|
+
return [f"({wallet_id}) && (({approve_enso}) || ({swap_enso})) "]
|
|
1680
|
+
|
|
1681
|
+
async def partial_liquidate(self, usd_value: float) -> StatusTuple:
|
|
1682
|
+
"""Liquidate strategy assets to reach target USD value in USDC."""
|
|
1683
|
+
# Refresh tracked balances
|
|
1684
|
+
await self._refresh_tracked_balances()
|
|
1685
|
+
|
|
1686
|
+
usdc_token_id = self.usdc_token_info.get("token_id")
|
|
1687
|
+
usdc_decimals = self.usdc_token_info.get("decimals")
|
|
1688
|
+
gas_token_id = self.gas_token.get("id") if self.gas_token else None
|
|
1689
|
+
|
|
1690
|
+
# Check current USDC balance
|
|
1691
|
+
available_usdc_wei = self.tracked_balances.get(usdc_token_id, 0)
|
|
1692
|
+
available_usdc_usd = float(available_usdc_wei) / (10**usdc_decimals)
|
|
1693
|
+
|
|
1694
|
+
# Liquidate non-USDC, non-gas, non-current-pool tokens first
|
|
1695
|
+
for token_id, balance_wei in list(self.tracked_balances.items()):
|
|
1696
|
+
if available_usdc_usd >= usd_value:
|
|
1697
|
+
break
|
|
1698
|
+
|
|
1699
|
+
# Skip USDC, gas, and current pool
|
|
1700
|
+
if token_id == usdc_token_id:
|
|
1701
|
+
continue
|
|
1702
|
+
if token_id == gas_token_id:
|
|
1703
|
+
continue
|
|
1704
|
+
if self.current_pool and token_id == self.current_pool.get("token_id"):
|
|
1705
|
+
continue
|
|
1706
|
+
|
|
1707
|
+
# Skip zero balances
|
|
1708
|
+
if balance_wei <= 0:
|
|
1709
|
+
continue
|
|
1710
|
+
|
|
1711
|
+
# Get token info and price
|
|
1712
|
+
try:
|
|
1713
|
+
success, token_info = await self.token_adapter.get_token(token_id)
|
|
1714
|
+
if not success:
|
|
1715
|
+
continue
|
|
1716
|
+
|
|
1717
|
+
success, price_data = await self.token_adapter.get_token_price(token_id)
|
|
1718
|
+
if not success:
|
|
1719
|
+
continue
|
|
1720
|
+
|
|
1721
|
+
decimals = token_info.get("decimals", 18)
|
|
1722
|
+
price = price_data.get("current_price", 0.0)
|
|
1723
|
+
token_usd_value = price * float(balance_wei) / (10**decimals)
|
|
1724
|
+
|
|
1725
|
+
if token_usd_value > 1.0:
|
|
1726
|
+
needed_usd = usd_value - available_usdc_usd
|
|
1727
|
+
required_token_wei = int(
|
|
1728
|
+
math.ceil((needed_usd * (10**decimals)) / price)
|
|
1729
|
+
)
|
|
1730
|
+
amount_to_swap = min(required_token_wei, balance_wei)
|
|
1731
|
+
|
|
1732
|
+
logger.info(f"Liquidating {token_id} to USDC: {amount_to_swap} wei")
|
|
1733
|
+
success, msg = await self.brap_adapter.swap_from_token_ids(
|
|
1734
|
+
token_id,
|
|
1735
|
+
f"{self.usdc_token_info.get('chain').get('code')}_{self.usdc_token_info.get('address').lower()}",
|
|
1736
|
+
self._get_strategy_wallet_address(),
|
|
1737
|
+
str(amount_to_swap),
|
|
1738
|
+
strategy_name=self.name,
|
|
1739
|
+
)
|
|
1740
|
+
if success:
|
|
1741
|
+
swapped_usd = (amount_to_swap / (10**decimals)) * price
|
|
1742
|
+
available_usdc_usd += swapped_usd
|
|
1743
|
+
# Update tracked state
|
|
1744
|
+
self._update_balance(token_id, balance_wei - amount_to_swap)
|
|
1745
|
+
else:
|
|
1746
|
+
logger.warning(f"Failed to liquidate {token_id}: {msg}")
|
|
1747
|
+
except Exception as e:
|
|
1748
|
+
logger.error(f"Error liquidating {token_id}: {e}")
|
|
1749
|
+
continue
|
|
1750
|
+
|
|
1751
|
+
# Refresh USDC balance after swaps
|
|
1752
|
+
success, usdc_wei = await self.balance_adapter.get_balance(
|
|
1753
|
+
token_id=usdc_token_id,
|
|
1754
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
1755
|
+
)
|
|
1756
|
+
if success and usdc_wei:
|
|
1757
|
+
available_usdc_wei = int(usdc_wei)
|
|
1758
|
+
available_usdc_usd = float(available_usdc_wei) / (10**usdc_decimals)
|
|
1759
|
+
self._update_balance(usdc_token_id, available_usdc_wei)
|
|
1760
|
+
|
|
1761
|
+
# If still not enough, liquidate from current pool
|
|
1762
|
+
if (
|
|
1763
|
+
available_usdc_usd < usd_value
|
|
1764
|
+
and self.current_pool
|
|
1765
|
+
and self.current_pool.get("token_id") != usdc_token_id
|
|
1766
|
+
):
|
|
1767
|
+
remaining_usd = usd_value - available_usdc_usd
|
|
1768
|
+
pool_balance_wei = self.tracked_balances.get(
|
|
1769
|
+
self.current_pool.get("token_id"), 0
|
|
1770
|
+
)
|
|
1771
|
+
pool_decimals = self.current_pool.get("decimals")
|
|
1772
|
+
amount_to_swap = min(
|
|
1773
|
+
pool_balance_wei, int(remaining_usd * (10**pool_decimals))
|
|
1774
|
+
)
|
|
1775
|
+
|
|
1776
|
+
if amount_to_swap > 0:
|
|
1777
|
+
try:
|
|
1778
|
+
logger.info(
|
|
1779
|
+
f"Liquidating from current pool {self.current_pool.get('token_id')}"
|
|
1780
|
+
)
|
|
1781
|
+
success, msg = await self.brap_adapter.swap_from_token_ids(
|
|
1782
|
+
self.current_pool.get("token_id"),
|
|
1783
|
+
f"{self.usdc_token_info.get('chain').get('code')}_{self.usdc_token_info.get('address').lower()}",
|
|
1784
|
+
self._get_strategy_wallet_address(),
|
|
1785
|
+
str(amount_to_swap),
|
|
1786
|
+
strategy_name=self.name,
|
|
1787
|
+
)
|
|
1788
|
+
if success:
|
|
1789
|
+
self._update_balance(
|
|
1790
|
+
self.current_pool.get("token_id"),
|
|
1791
|
+
pool_balance_wei - amount_to_swap,
|
|
1792
|
+
)
|
|
1793
|
+
except Exception as e:
|
|
1794
|
+
logger.error(f"Error swapping pool to USDC: {e}")
|
|
1795
|
+
|
|
1796
|
+
# Refresh USDC balance again
|
|
1797
|
+
success, usdc_wei = await self.balance_adapter.get_balance(
|
|
1798
|
+
token_id=usdc_token_id,
|
|
1799
|
+
wallet_address=self._get_strategy_wallet_address(),
|
|
1800
|
+
)
|
|
1801
|
+
if success and usdc_wei:
|
|
1802
|
+
available_usdc_wei = int(usdc_wei)
|
|
1803
|
+
self._update_balance(usdc_token_id, available_usdc_wei)
|
|
1804
|
+
|
|
1805
|
+
to_pay = min(available_usdc_wei, int(usd_value * (10**usdc_decimals)))
|
|
1806
|
+
to_pay_usd = float(to_pay) / (10**usdc_decimals)
|
|
1807
|
+
return (
|
|
1808
|
+
True,
|
|
1809
|
+
f"Partial liquidation completed. Available: {to_pay_usd:.2f} USDC",
|
|
1810
|
+
)
|