wayfinder-paths 0.1.8__py3-none-any.whl → 0.1.10__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 +6 -15
- wayfinder_paths/adapters/balance_adapter/README.md +1 -2
- wayfinder_paths/adapters/balance_adapter/adapter.py +4 -4
- wayfinder_paths/adapters/brap_adapter/README.md +1 -1
- wayfinder_paths/adapters/brap_adapter/adapter.py +139 -74
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +0 -7
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +0 -54
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +1 -1
- wayfinder_paths/adapters/ledger_adapter/README.md +1 -1
- wayfinder_paths/adapters/moonwell_adapter/README.md +174 -0
- wayfinder_paths/adapters/moonwell_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +1226 -0
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +635 -0
- wayfinder_paths/adapters/pool_adapter/README.md +1 -77
- wayfinder_paths/adapters/pool_adapter/adapter.py +0 -122
- wayfinder_paths/adapters/pool_adapter/examples.json +0 -57
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +0 -86
- wayfinder_paths/adapters/token_adapter/README.md +1 -1
- wayfinder_paths/core/clients/ClientManager.py +1 -22
- wayfinder_paths/core/clients/WalletClient.py +0 -8
- wayfinder_paths/core/clients/WayfinderClient.py +7 -12
- wayfinder_paths/core/clients/__init__.py +0 -8
- wayfinder_paths/core/clients/protocols.py +0 -60
- wayfinder_paths/core/config.py +5 -45
- wayfinder_paths/core/constants/__init__.py +0 -2
- wayfinder_paths/core/constants/base.py +6 -2
- wayfinder_paths/core/constants/moonwell_abi.py +411 -0
- wayfinder_paths/core/services/base.py +7 -1
- wayfinder_paths/core/services/local_evm_txn.py +223 -222
- wayfinder_paths/core/services/local_token_txn.py +103 -92
- wayfinder_paths/core/services/web3_service.py +0 -2
- wayfinder_paths/core/settings.py +8 -8
- wayfinder_paths/core/strategies/Strategy.py +1 -5
- wayfinder_paths/core/strategies/descriptors.py +1 -1
- wayfinder_paths/core/utils/evm_helpers.py +7 -12
- wayfinder_paths/core/wallets/README.md +3 -6
- wayfinder_paths/run_strategy.py +62 -105
- wayfinder_paths/scripts/create_strategy.py +2 -27
- wayfinder_paths/scripts/make_wallets.py +1 -25
- wayfinder_paths/scripts/run_strategy.py +37 -9
- wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1 -3
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +87 -138
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +96 -58
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +2 -17
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +4 -1
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +107 -29
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +53 -14
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +108 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/examples.json +11 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2975 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +886 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +0 -7
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +2 -7
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +0 -4
- wayfinder_paths/templates/adapter/README.md +5 -21
- wayfinder_paths/templates/adapter/adapter.py +1 -2
- wayfinder_paths/templates/adapter/test_adapter.py +1 -1
- wayfinder_paths/templates/strategy/README.md +4 -21
- wayfinder_paths/templates/strategy/test_strategy.py +0 -4
- wayfinder_paths/tests/test_smoke_manifest.py +17 -2
- {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/METADATA +64 -201
- {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/RECORD +64 -71
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +0 -8
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +0 -11
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +0 -10
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +0 -8
- wayfinder_paths/adapters/ledger_adapter/manifest.yaml +0 -11
- wayfinder_paths/adapters/pool_adapter/manifest.yaml +0 -10
- wayfinder_paths/adapters/token_adapter/manifest.yaml +0 -6
- wayfinder_paths/core/clients/SimulationClient.py +0 -192
- wayfinder_paths/core/clients/TransactionClient.py +0 -63
- wayfinder_paths/core/engine/manifest.py +0 -97
- wayfinder_paths/scripts/validate_manifests.py +0 -213
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +0 -23
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +0 -7
- wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +0 -17
- wayfinder_paths/templates/adapter/manifest.yaml +0 -6
- wayfinder_paths/templates/strategy/manifest.yaml +0 -8
- {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
"""Test template for Moonwell wstETH Loop Strategy."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy import (
|
|
9
|
+
ETH_TOKEN_ID,
|
|
10
|
+
M_USDC,
|
|
11
|
+
M_WETH,
|
|
12
|
+
M_WSTETH,
|
|
13
|
+
USDC_TOKEN_ID,
|
|
14
|
+
WETH,
|
|
15
|
+
WETH_TOKEN_ID,
|
|
16
|
+
WSTETH_TOKEN_ID,
|
|
17
|
+
MoonwellWstethLoopStrategy,
|
|
18
|
+
SwapOutcomeUnknownError,
|
|
19
|
+
)
|
|
20
|
+
from wayfinder_paths.tests.test_utils import (
|
|
21
|
+
get_canonical_examples,
|
|
22
|
+
load_strategy_examples,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def strategy():
|
|
28
|
+
"""Create a strategy instance for testing with minimal config."""
|
|
29
|
+
mock_config = {
|
|
30
|
+
"main_wallet": {"address": "0x1234567890123456789012345678901234567890"},
|
|
31
|
+
"strategy_wallet": {"address": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Patch the initialization to avoid real adapter/web3 setup
|
|
35
|
+
with patch.object(
|
|
36
|
+
MoonwellWstethLoopStrategy, "__init__", lambda self, **kwargs: None
|
|
37
|
+
):
|
|
38
|
+
s = MoonwellWstethLoopStrategy(
|
|
39
|
+
config=mock_config,
|
|
40
|
+
main_wallet=mock_config["main_wallet"],
|
|
41
|
+
strategy_wallet=mock_config["strategy_wallet"],
|
|
42
|
+
simulation=True,
|
|
43
|
+
)
|
|
44
|
+
# Manually set attributes that would be set in __init__
|
|
45
|
+
s.config = mock_config
|
|
46
|
+
s.simulation = True
|
|
47
|
+
s._token_info_cache = {}
|
|
48
|
+
s._token_price_cache = {}
|
|
49
|
+
s._token_price_timestamps = {}
|
|
50
|
+
|
|
51
|
+
# Mock adapters
|
|
52
|
+
s.balance_adapter = MagicMock()
|
|
53
|
+
s.moonwell_adapter = MagicMock()
|
|
54
|
+
s.brap_adapter = MagicMock()
|
|
55
|
+
s.token_adapter = MagicMock()
|
|
56
|
+
s.ledger_adapter = MagicMock()
|
|
57
|
+
s.ledger_adapter.record_strategy_snapshot = AsyncMock(return_value=None)
|
|
58
|
+
|
|
59
|
+
return s
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def mock_adapter_responses(strategy):
|
|
64
|
+
"""Set up mock responses for adapter calls."""
|
|
65
|
+
# Mock balance adapter
|
|
66
|
+
strategy.balance_adapter.get_balance = AsyncMock(return_value=(True, 1000000))
|
|
67
|
+
strategy.balance_adapter.move_from_main_wallet_to_strategy_wallet = AsyncMock(
|
|
68
|
+
return_value=(True, "success")
|
|
69
|
+
)
|
|
70
|
+
strategy.balance_adapter.move_from_strategy_wallet_to_main_wallet = AsyncMock(
|
|
71
|
+
return_value=(True, "success")
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Mock token adapter
|
|
75
|
+
strategy.token_adapter.get_token = AsyncMock(
|
|
76
|
+
return_value=(True, {"decimals": 18, "symbol": "TEST"})
|
|
77
|
+
)
|
|
78
|
+
strategy.token_adapter.get_token_price = AsyncMock(
|
|
79
|
+
return_value=(True, {"current_price": 1.0})
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Mock moonwell adapter
|
|
83
|
+
strategy.moonwell_adapter.get_pos = AsyncMock(
|
|
84
|
+
return_value=(
|
|
85
|
+
True,
|
|
86
|
+
{
|
|
87
|
+
"mtoken_balance": 1000000000000000000,
|
|
88
|
+
"underlying_balance": 1000000000000000000,
|
|
89
|
+
"borrow_balance": 0,
|
|
90
|
+
"exchange_rate": 1000000000000000000,
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
strategy.moonwell_adapter.get_collateral_factor = AsyncMock(
|
|
95
|
+
return_value=(True, 0.8)
|
|
96
|
+
)
|
|
97
|
+
strategy.moonwell_adapter.get_apy = AsyncMock(return_value=(True, 0.05))
|
|
98
|
+
strategy.moonwell_adapter.get_borrowable_amount = AsyncMock(
|
|
99
|
+
return_value=(True, 1000.0)
|
|
100
|
+
)
|
|
101
|
+
strategy.moonwell_adapter.max_withdrawable_mtoken = AsyncMock(
|
|
102
|
+
return_value=(True, {"cTokens_raw": 1000000, "underlying_raw": 1000000})
|
|
103
|
+
)
|
|
104
|
+
strategy.moonwell_adapter.lend = AsyncMock(return_value=(True, "success"))
|
|
105
|
+
strategy.moonwell_adapter.unlend = AsyncMock(return_value=(True, "success"))
|
|
106
|
+
strategy.moonwell_adapter.borrow = AsyncMock(return_value=(True, "success"))
|
|
107
|
+
strategy.moonwell_adapter.repay = AsyncMock(return_value=(True, "success"))
|
|
108
|
+
strategy.moonwell_adapter.set_collateral = AsyncMock(return_value=(True, "success"))
|
|
109
|
+
strategy.moonwell_adapter.claim_rewards = AsyncMock(return_value={})
|
|
110
|
+
|
|
111
|
+
# Mock brap adapter
|
|
112
|
+
strategy.brap_adapter.swap_from_token_ids = AsyncMock(
|
|
113
|
+
return_value=(True, {"to_amount": 1000000000000000000})
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return strategy
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
@pytest.mark.smoke
|
|
121
|
+
async def test_smoke(strategy, mock_adapter_responses):
|
|
122
|
+
"""REQUIRED: Basic smoke test - verifies strategy lifecycle."""
|
|
123
|
+
examples = load_strategy_examples(Path(__file__))
|
|
124
|
+
smoke_data = examples["smoke"]
|
|
125
|
+
|
|
126
|
+
# Mock quote to return positive APY
|
|
127
|
+
with patch.object(strategy, "quote", new_callable=AsyncMock) as mock_quote:
|
|
128
|
+
mock_quote.return_value = {"apy": 0.1, "data": {}}
|
|
129
|
+
|
|
130
|
+
# Status test
|
|
131
|
+
with patch.object(strategy, "_status", new_callable=AsyncMock) as mock_status:
|
|
132
|
+
mock_status.return_value = {
|
|
133
|
+
"portfolio_value": 0.0,
|
|
134
|
+
"net_deposit": 0.0,
|
|
135
|
+
"strategy_status": {},
|
|
136
|
+
"gas_available": 0.1,
|
|
137
|
+
"gassed_up": True,
|
|
138
|
+
}
|
|
139
|
+
st = await strategy.status()
|
|
140
|
+
assert isinstance(st, dict)
|
|
141
|
+
assert (
|
|
142
|
+
"portfolio_value" in st
|
|
143
|
+
or "net_deposit" in st
|
|
144
|
+
or "strategy_status" in st
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Deposit test
|
|
148
|
+
deposit_params = smoke_data.get("deposit", {})
|
|
149
|
+
with patch.object(strategy, "deposit", new_callable=AsyncMock) as mock_deposit:
|
|
150
|
+
mock_deposit.return_value = (True, "success")
|
|
151
|
+
ok, msg = await strategy.deposit(**deposit_params)
|
|
152
|
+
assert isinstance(ok, bool)
|
|
153
|
+
assert isinstance(msg, str)
|
|
154
|
+
|
|
155
|
+
# Update test
|
|
156
|
+
with patch.object(strategy, "update", new_callable=AsyncMock) as mock_update:
|
|
157
|
+
mock_update.return_value = (True, "success")
|
|
158
|
+
ok, msg = await strategy.update(**smoke_data.get("update", {}))
|
|
159
|
+
assert isinstance(ok, bool)
|
|
160
|
+
|
|
161
|
+
# Withdraw test
|
|
162
|
+
with patch.object(
|
|
163
|
+
strategy, "withdraw", new_callable=AsyncMock
|
|
164
|
+
) as mock_withdraw:
|
|
165
|
+
mock_withdraw.return_value = (True, "success")
|
|
166
|
+
ok, msg = await strategy.withdraw(**smoke_data.get("withdraw", {}))
|
|
167
|
+
assert isinstance(ok, bool)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.asyncio
|
|
171
|
+
async def test_canonical_usage(strategy, mock_adapter_responses):
|
|
172
|
+
"""REQUIRED: Test canonical usage examples from examples.json (minimum)."""
|
|
173
|
+
examples = load_strategy_examples(Path(__file__))
|
|
174
|
+
canonical = get_canonical_examples(examples)
|
|
175
|
+
|
|
176
|
+
for example_name, example_data in canonical.items():
|
|
177
|
+
# Mock methods for canonical usage tests
|
|
178
|
+
with patch.object(strategy, "quote", new_callable=AsyncMock) as mock_quote:
|
|
179
|
+
mock_quote.return_value = {"apy": 0.1, "data": {}}
|
|
180
|
+
|
|
181
|
+
if "deposit" in example_data:
|
|
182
|
+
deposit_params = example_data.get("deposit", {})
|
|
183
|
+
with patch.object(
|
|
184
|
+
strategy, "deposit", new_callable=AsyncMock
|
|
185
|
+
) as mock_deposit:
|
|
186
|
+
mock_deposit.return_value = (True, "success")
|
|
187
|
+
ok, _ = await strategy.deposit(**deposit_params)
|
|
188
|
+
assert ok, f"Canonical example '{example_name}' deposit failed"
|
|
189
|
+
|
|
190
|
+
if "update" in example_data:
|
|
191
|
+
with patch.object(
|
|
192
|
+
strategy, "update", new_callable=AsyncMock
|
|
193
|
+
) as mock_update:
|
|
194
|
+
mock_update.return_value = (True, "success")
|
|
195
|
+
ok, msg = await strategy.update()
|
|
196
|
+
assert ok, (
|
|
197
|
+
f"Canonical example '{example_name}' update failed: {msg}"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if "status" in example_data:
|
|
201
|
+
with patch.object(
|
|
202
|
+
strategy, "_status", new_callable=AsyncMock
|
|
203
|
+
) as mock_status:
|
|
204
|
+
mock_status.return_value = {
|
|
205
|
+
"portfolio_value": 0.0,
|
|
206
|
+
"net_deposit": 0.0,
|
|
207
|
+
"strategy_status": {},
|
|
208
|
+
"gas_available": 0.1,
|
|
209
|
+
"gassed_up": True,
|
|
210
|
+
}
|
|
211
|
+
st = await strategy.status()
|
|
212
|
+
assert isinstance(st, dict), (
|
|
213
|
+
f"Canonical example '{example_name}' status failed"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@pytest.mark.asyncio
|
|
218
|
+
async def test_status_returns_status_dict(strategy, mock_adapter_responses):
|
|
219
|
+
"""Test that _status returns a proper StatusDict."""
|
|
220
|
+
with patch.object(
|
|
221
|
+
strategy, "_aggregate_positions", new_callable=AsyncMock
|
|
222
|
+
) as mock_pos:
|
|
223
|
+
mock_pos.return_value = ({}, {})
|
|
224
|
+
with patch.object(strategy, "compute_ltv", new_callable=AsyncMock) as mock_ltv:
|
|
225
|
+
mock_ltv.return_value = 0.5
|
|
226
|
+
with patch.object(
|
|
227
|
+
strategy, "_get_gas_balance", new_callable=AsyncMock
|
|
228
|
+
) as mock_gas:
|
|
229
|
+
mock_gas.return_value = 100000000000000000 # 0.1 ETH
|
|
230
|
+
with patch.object(
|
|
231
|
+
strategy, "get_peg_diff", new_callable=AsyncMock
|
|
232
|
+
) as mock_peg:
|
|
233
|
+
mock_peg.return_value = 0.001
|
|
234
|
+
with patch.object(
|
|
235
|
+
strategy, "quote", new_callable=AsyncMock
|
|
236
|
+
) as mock_quote:
|
|
237
|
+
mock_quote.return_value = {"apy": 0.1, "data": {}}
|
|
238
|
+
|
|
239
|
+
status = await strategy._status()
|
|
240
|
+
|
|
241
|
+
assert "portfolio_value" in status
|
|
242
|
+
assert "net_deposit" in status
|
|
243
|
+
assert "strategy_status" in status
|
|
244
|
+
assert "gas_available" in status
|
|
245
|
+
assert "gassed_up" in status
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@pytest.mark.asyncio
|
|
249
|
+
async def test_policies_returns_list(strategy):
|
|
250
|
+
"""Test that policies returns a non-empty list."""
|
|
251
|
+
# Mock the policy functions to avoid ABI fetching
|
|
252
|
+
with (
|
|
253
|
+
patch(
|
|
254
|
+
"wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.musdc_mint_or_approve_or_redeem",
|
|
255
|
+
new_callable=AsyncMock,
|
|
256
|
+
return_value="mock_musdc_policy",
|
|
257
|
+
),
|
|
258
|
+
patch(
|
|
259
|
+
"wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.mweth_approve_or_borrow_or_repay",
|
|
260
|
+
new_callable=AsyncMock,
|
|
261
|
+
return_value="mock_mweth_policy",
|
|
262
|
+
),
|
|
263
|
+
patch(
|
|
264
|
+
"wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.mwsteth_approve_or_mint_or_redeem",
|
|
265
|
+
new_callable=AsyncMock,
|
|
266
|
+
return_value="mock_mwsteth_policy",
|
|
267
|
+
),
|
|
268
|
+
patch(
|
|
269
|
+
"wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.moonwell_comptroller_enter_markets_or_claim_rewards",
|
|
270
|
+
new_callable=AsyncMock,
|
|
271
|
+
return_value="mock_comptroller_policy",
|
|
272
|
+
),
|
|
273
|
+
patch(
|
|
274
|
+
"wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.weth_deposit",
|
|
275
|
+
new_callable=AsyncMock,
|
|
276
|
+
return_value="mock_weth_deposit_policy",
|
|
277
|
+
),
|
|
278
|
+
patch(
|
|
279
|
+
"wayfinder_paths.strategies.moonwell_wsteth_loop_strategy.strategy.enso_swap",
|
|
280
|
+
new_callable=AsyncMock,
|
|
281
|
+
return_value="mock_enso_swap_policy",
|
|
282
|
+
),
|
|
283
|
+
):
|
|
284
|
+
policies = await strategy.policies()
|
|
285
|
+
assert isinstance(policies, list)
|
|
286
|
+
assert len(policies) > 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@pytest.mark.asyncio
|
|
290
|
+
async def test_quote_returns_apy_info(strategy, mock_adapter_responses):
|
|
291
|
+
"""Test that quote returns APY information."""
|
|
292
|
+
with patch("httpx.AsyncClient") as mock_client:
|
|
293
|
+
mock_response = MagicMock()
|
|
294
|
+
mock_response.json.return_value = {"data": {"smaApr": 3.5}}
|
|
295
|
+
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
|
296
|
+
return_value=mock_response
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
quote = await strategy.quote()
|
|
300
|
+
|
|
301
|
+
assert "apy" in quote
|
|
302
|
+
assert "information" in quote or "data" in quote
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# Tests for new safety methods
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_max_safe_f_calculates_correctly(strategy):
|
|
309
|
+
"""Test that _max_safe_F calculates the depeg-aware leverage limit correctly."""
|
|
310
|
+
# Set up strategy with MAX_DEPEG = 0.01 (1%)
|
|
311
|
+
strategy.MAX_DEPEG = 0.01
|
|
312
|
+
|
|
313
|
+
# With cf_w = 0.8, a = 0.99
|
|
314
|
+
# F_max = 1 / (1 + 0.8 * (1 - 0.99)) = 1 / (1 + 0.8 * 0.01) = 1 / 1.008 ≈ 0.992
|
|
315
|
+
result = strategy._max_safe_F(0.8)
|
|
316
|
+
expected = 1 / (1 + 0.8 * 0.01)
|
|
317
|
+
assert abs(result - expected) < 0.001
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_max_safe_f_with_zero_collateral_factor(strategy):
|
|
321
|
+
"""Test _max_safe_F with zero collateral factor."""
|
|
322
|
+
strategy.MAX_DEPEG = 0.01
|
|
323
|
+
result = strategy._max_safe_F(0.0)
|
|
324
|
+
# F_max = 1 / (1 + 0 * anything) = 1.0
|
|
325
|
+
assert result == 1.0
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@pytest.mark.asyncio
|
|
329
|
+
async def test_swap_with_retries_succeeds_first_attempt(
|
|
330
|
+
strategy, mock_adapter_responses
|
|
331
|
+
):
|
|
332
|
+
"""Test that _swap_with_retries succeeds on first attempt."""
|
|
333
|
+
strategy.max_swap_retries = 3
|
|
334
|
+
strategy.swap_slippage_tolerance = 0.005
|
|
335
|
+
|
|
336
|
+
result = await strategy._swap_with_retries(
|
|
337
|
+
from_token_id="usd-coin-base",
|
|
338
|
+
to_token_id="l2-standard-bridged-weth-base-base",
|
|
339
|
+
amount=1000000,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
assert result is not None
|
|
343
|
+
assert "to_amount" in result
|
|
344
|
+
strategy.brap_adapter.swap_from_token_ids.assert_called_once()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@pytest.mark.asyncio
|
|
348
|
+
async def test_swap_with_retries_succeeds_on_second_attempt(
|
|
349
|
+
strategy, mock_adapter_responses
|
|
350
|
+
):
|
|
351
|
+
"""Test that _swap_with_retries retries and succeeds."""
|
|
352
|
+
strategy.max_swap_retries = 3
|
|
353
|
+
strategy.swap_slippage_tolerance = 0.005
|
|
354
|
+
|
|
355
|
+
# First call fails, second succeeds
|
|
356
|
+
strategy.brap_adapter.swap_from_token_ids = AsyncMock(
|
|
357
|
+
side_effect=[
|
|
358
|
+
Exception("First attempt failed"),
|
|
359
|
+
(True, {"to_amount": 1000000}),
|
|
360
|
+
]
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
with patch("asyncio.sleep", new_callable=AsyncMock): # Speed up test
|
|
364
|
+
result = await strategy._swap_with_retries(
|
|
365
|
+
from_token_id="usd-coin-base",
|
|
366
|
+
to_token_id="l2-standard-bridged-weth-base-base",
|
|
367
|
+
amount=1000000,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
assert result is not None
|
|
371
|
+
assert result["to_amount"] == 1000000
|
|
372
|
+
assert strategy.brap_adapter.swap_from_token_ids.call_count == 2
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@pytest.mark.asyncio
|
|
376
|
+
async def test_swap_with_retries_fails_all_attempts(strategy, mock_adapter_responses):
|
|
377
|
+
"""Test that _swap_with_retries returns None after all attempts fail."""
|
|
378
|
+
strategy.max_swap_retries = 3
|
|
379
|
+
strategy.swap_slippage_tolerance = 0.005
|
|
380
|
+
|
|
381
|
+
strategy.brap_adapter.swap_from_token_ids = AsyncMock(
|
|
382
|
+
side_effect=Exception("Swap failed")
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
with patch("asyncio.sleep", new_callable=AsyncMock):
|
|
386
|
+
result = await strategy._swap_with_retries(
|
|
387
|
+
from_token_id="usd-coin-base",
|
|
388
|
+
to_token_id="l2-standard-bridged-weth-base-base",
|
|
389
|
+
amount=1000000,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
assert result is None
|
|
393
|
+
assert strategy.brap_adapter.swap_from_token_ids.call_count == 3
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@pytest.mark.asyncio
|
|
397
|
+
async def test_swap_with_retries_aborts_on_unknown_outcome(
|
|
398
|
+
strategy, mock_adapter_responses
|
|
399
|
+
):
|
|
400
|
+
"""Swap retries must abort (no retry) when the transaction outcome is unknown."""
|
|
401
|
+
strategy.max_swap_retries = 3
|
|
402
|
+
strategy.brap_adapter.swap_from_token_ids = AsyncMock(
|
|
403
|
+
return_value=(
|
|
404
|
+
False,
|
|
405
|
+
"Transaction HexBytes('0xdeadbeef') is not in the chain after 120 seconds",
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
with pytest.raises(SwapOutcomeUnknownError):
|
|
410
|
+
await strategy._swap_with_retries(
|
|
411
|
+
from_token_id=USDC_TOKEN_ID,
|
|
412
|
+
to_token_id=WETH_TOKEN_ID,
|
|
413
|
+
amount=1000000,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
assert strategy.brap_adapter.swap_from_token_ids.call_count == 1
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@pytest.mark.asyncio
|
|
420
|
+
async def test_balance_weth_debt_no_action_when_balanced(
|
|
421
|
+
strategy, mock_adapter_responses
|
|
422
|
+
):
|
|
423
|
+
"""Test that _balance_weth_debt does nothing when debt is balanced."""
|
|
424
|
+
# Mock wstETH position: 2 ETH worth
|
|
425
|
+
strategy.moonwell_adapter.get_pos = AsyncMock(
|
|
426
|
+
side_effect=[
|
|
427
|
+
(True, {"underlying_balance": 2 * 10**18, "borrow_balance": 0}), # wstETH
|
|
428
|
+
(
|
|
429
|
+
True,
|
|
430
|
+
{"underlying_balance": 0, "borrow_balance": 1 * 10**18},
|
|
431
|
+
), # WETH debt
|
|
432
|
+
]
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Mock prices: wstETH = $2000, WETH = $2000 (so 2 wstETH > 1 WETH debt)
|
|
436
|
+
strategy.token_adapter.get_token_price = AsyncMock(
|
|
437
|
+
return_value=(True, {"current_price": 2000.0})
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
success, msg = await strategy._balance_weth_debt()
|
|
441
|
+
|
|
442
|
+
assert success is True
|
|
443
|
+
assert "balanced" in msg.lower()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@pytest.mark.asyncio
|
|
447
|
+
async def test_balance_weth_debt_rebalances_when_excess_debt(
|
|
448
|
+
strategy, mock_adapter_responses
|
|
449
|
+
):
|
|
450
|
+
"""Test that _balance_weth_debt attempts to rebalance when debt exceeds collateral."""
|
|
451
|
+
# Mock positions: wstETH value < WETH debt
|
|
452
|
+
strategy.moonwell_adapter.get_pos = AsyncMock(
|
|
453
|
+
side_effect=[
|
|
454
|
+
(True, {"underlying_balance": 1 * 10**18}), # 1 wstETH
|
|
455
|
+
(True, {"borrow_balance": 2 * 10**18}), # 2 WETH debt (excess)
|
|
456
|
+
]
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Mock prices
|
|
460
|
+
strategy.token_adapter.get_token_price = AsyncMock(
|
|
461
|
+
return_value=(True, {"current_price": 2000.0})
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Mock wallet balances (has WETH to repay)
|
|
465
|
+
strategy.balance_adapter.get_balance = AsyncMock(
|
|
466
|
+
side_effect=[
|
|
467
|
+
(True, 1 * 10**18), # WETH balance
|
|
468
|
+
(True, 0), # ETH balance
|
|
469
|
+
]
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
strategy.MIN_GAS = 0.005
|
|
473
|
+
|
|
474
|
+
success, msg = await strategy._balance_weth_debt()
|
|
475
|
+
|
|
476
|
+
# Should have attempted repayment
|
|
477
|
+
strategy.moonwell_adapter.repay.assert_called()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@pytest.mark.asyncio
|
|
481
|
+
async def test_balance_weth_debt_rebalances_when_no_wsteth_position(
|
|
482
|
+
strategy, mock_adapter_responses
|
|
483
|
+
):
|
|
484
|
+
"""Test that _balance_weth_debt still rebalances when wstETH position fetch fails."""
|
|
485
|
+
strategy.moonwell_adapter.get_pos = AsyncMock(
|
|
486
|
+
side_effect=[
|
|
487
|
+
(False, "rpc error"), # wstETH (treat as 0)
|
|
488
|
+
(True, {"borrow_balance": 2 * 10**18}), # WETH debt
|
|
489
|
+
]
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
strategy.token_adapter.get_token_price = AsyncMock(
|
|
493
|
+
return_value=(True, {"current_price": 2000.0})
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
strategy.balance_adapter.get_balance = AsyncMock(
|
|
497
|
+
side_effect=[
|
|
498
|
+
(True, 2 * 10**18), # WETH balance (enough to repay)
|
|
499
|
+
]
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
success, msg = await strategy._balance_weth_debt()
|
|
503
|
+
|
|
504
|
+
assert success is True
|
|
505
|
+
assert "balanced" in msg.lower()
|
|
506
|
+
strategy.moonwell_adapter.repay.assert_called()
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@pytest.mark.asyncio
|
|
510
|
+
async def test_atomic_deposit_iteration_swaps_from_eth_when_borrow_surfaces_as_eth(
|
|
511
|
+
strategy, mock_adapter_responses
|
|
512
|
+
):
|
|
513
|
+
"""Borrowed WETH can surface as native ETH; iteration should wrap ETH→WETH then swap WETH→wstETH."""
|
|
514
|
+
# Ensure gas reserve exists so we don't drain to 0
|
|
515
|
+
strategy.WRAP_GAS_RESERVE = 0.0014
|
|
516
|
+
|
|
517
|
+
borrow_amt_wei = 10**18
|
|
518
|
+
safe_borrow = int(borrow_amt_wei * 0.98)
|
|
519
|
+
|
|
520
|
+
balances: dict[str, int] = {
|
|
521
|
+
ETH_TOKEN_ID: 2 * 10**18,
|
|
522
|
+
WETH_TOKEN_ID: 0,
|
|
523
|
+
WSTETH_TOKEN_ID: 0,
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async def get_balance_side_effect(*, token_id: str, wallet_address: str, **_):
|
|
527
|
+
return (True, balances.get(token_id, 0))
|
|
528
|
+
|
|
529
|
+
strategy.balance_adapter.get_balance = AsyncMock(
|
|
530
|
+
side_effect=get_balance_side_effect
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
async def borrow_side_effect(*, mtoken: str, amount: int):
|
|
534
|
+
# Borrow shows up as native ETH (simulates on-chain behavior)
|
|
535
|
+
assert mtoken == M_WETH
|
|
536
|
+
balances[ETH_TOKEN_ID] += int(amount)
|
|
537
|
+
return (True, {"block_number": 12345})
|
|
538
|
+
|
|
539
|
+
strategy.moonwell_adapter.borrow = AsyncMock(side_effect=borrow_side_effect)
|
|
540
|
+
|
|
541
|
+
async def wrap_eth_side_effect(*, amount: int):
|
|
542
|
+
# Wrap ETH to WETH
|
|
543
|
+
balances[ETH_TOKEN_ID] -= int(amount)
|
|
544
|
+
balances[WETH_TOKEN_ID] += int(amount)
|
|
545
|
+
return (True, {"block_number": 12346})
|
|
546
|
+
|
|
547
|
+
strategy.moonwell_adapter.wrap_eth = AsyncMock(side_effect=wrap_eth_side_effect)
|
|
548
|
+
|
|
549
|
+
async def swap_side_effect(
|
|
550
|
+
*, from_token_id: str, to_token_id: str, amount: int, **_
|
|
551
|
+
):
|
|
552
|
+
# After wrapping, the swap should be WETH→wstETH
|
|
553
|
+
assert from_token_id == WETH_TOKEN_ID
|
|
554
|
+
assert to_token_id == WSTETH_TOKEN_ID
|
|
555
|
+
# Simulate receiving wstETH
|
|
556
|
+
balances[WSTETH_TOKEN_ID] += 123
|
|
557
|
+
return {"to_amount": 123}
|
|
558
|
+
|
|
559
|
+
strategy._swap_with_retries = AsyncMock(side_effect=swap_side_effect)
|
|
560
|
+
|
|
561
|
+
lent = await strategy._atomic_deposit_iteration(borrow_amt_wei)
|
|
562
|
+
|
|
563
|
+
assert lent == 123
|
|
564
|
+
strategy.moonwell_adapter.borrow.assert_called_once_with(
|
|
565
|
+
mtoken=M_WETH, amount=safe_borrow
|
|
566
|
+
)
|
|
567
|
+
strategy.moonwell_adapter.wrap_eth.assert_called_once()
|
|
568
|
+
strategy._swap_with_retries.assert_called_once()
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@pytest.mark.asyncio
|
|
572
|
+
async def test_complete_unpaired_weth_borrow_uses_eth_inventory(
|
|
573
|
+
strategy, mock_adapter_responses
|
|
574
|
+
):
|
|
575
|
+
"""If debt exists but wstETH collateral is missing, use wallet ETH to complete the loop."""
|
|
576
|
+
# wstETH pos is empty; WETH debt exists
|
|
577
|
+
strategy.moonwell_adapter.get_pos = AsyncMock(
|
|
578
|
+
side_effect=[
|
|
579
|
+
(True, {"underlying_balance": 0}), # mwstETH
|
|
580
|
+
(True, {"borrow_balance": 5 * 10**18}), # mWETH debt
|
|
581
|
+
]
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
balances: dict[str, int] = {
|
|
585
|
+
ETH_TOKEN_ID: 10 * 10**18,
|
|
586
|
+
WETH_TOKEN_ID: 0,
|
|
587
|
+
WSTETH_TOKEN_ID: 0,
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async def get_balance_side_effect(*, token_id: str, wallet_address: str):
|
|
591
|
+
return (True, balances.get(token_id, 0))
|
|
592
|
+
|
|
593
|
+
strategy.balance_adapter.get_balance = AsyncMock(
|
|
594
|
+
side_effect=get_balance_side_effect
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
async def swap_side_effect(
|
|
598
|
+
*, from_token_id: str, to_token_id: str, amount: int, **_
|
|
599
|
+
):
|
|
600
|
+
assert from_token_id == ETH_TOKEN_ID
|
|
601
|
+
assert to_token_id == WSTETH_TOKEN_ID
|
|
602
|
+
balances[WSTETH_TOKEN_ID] += 7 * 10**18
|
|
603
|
+
return {"to_amount": 7 * 10**18}
|
|
604
|
+
|
|
605
|
+
strategy._swap_with_retries = AsyncMock(side_effect=swap_side_effect)
|
|
606
|
+
|
|
607
|
+
success, msg = await strategy._complete_unpaired_weth_borrow()
|
|
608
|
+
|
|
609
|
+
assert success is True
|
|
610
|
+
assert "completed unpaired borrow" in msg.lower()
|
|
611
|
+
strategy.moonwell_adapter.lend.assert_called()
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@pytest.mark.asyncio
|
|
615
|
+
async def test_sweep_token_balances_no_tokens(strategy, mock_adapter_responses):
|
|
616
|
+
"""Test that _sweep_token_balances handles empty wallet."""
|
|
617
|
+
# All balances are 0
|
|
618
|
+
strategy.balance_adapter.get_balance = AsyncMock(return_value=(True, 0))
|
|
619
|
+
|
|
620
|
+
success, msg = await strategy._sweep_token_balances(
|
|
621
|
+
target_token_id="usd-coin-base",
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
assert success is True
|
|
625
|
+
assert "no tokens" in msg.lower()
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
@pytest.mark.asyncio
|
|
629
|
+
async def test_sweep_token_balances_sweeps_tokens(strategy, mock_adapter_responses):
|
|
630
|
+
"""Test that _sweep_token_balances converts dust tokens."""
|
|
631
|
+
strategy.min_withdraw_usd = 1.0
|
|
632
|
+
|
|
633
|
+
# Mock balance returns (has some WETH dust)
|
|
634
|
+
def balance_side_effect(token_id, wallet_address):
|
|
635
|
+
if "weth" in token_id.lower():
|
|
636
|
+
return (True, 100 * 10**18) # 100 WETH
|
|
637
|
+
return (True, 0)
|
|
638
|
+
|
|
639
|
+
strategy.balance_adapter.get_balance = AsyncMock(side_effect=balance_side_effect)
|
|
640
|
+
|
|
641
|
+
# Mock price (high enough to trigger sweep)
|
|
642
|
+
strategy.token_adapter.get_token_price = AsyncMock(
|
|
643
|
+
return_value=(True, {"current_price": 2000.0})
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
success, msg = await strategy._sweep_token_balances(
|
|
647
|
+
target_token_id="usd-coin-base",
|
|
648
|
+
exclude=set(),
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
assert success is True
|
|
652
|
+
# Should have called swap
|
|
653
|
+
strategy.brap_adapter.swap_from_token_ids.assert_called()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# Tests for code review fixes
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@pytest.mark.asyncio
|
|
660
|
+
async def test_deposit_rejects_zero_amount(strategy):
|
|
661
|
+
"""Test that deposit rejects zero or negative amounts."""
|
|
662
|
+
result = await strategy.deposit(main_token_amount=0.0)
|
|
663
|
+
assert result[0] is False
|
|
664
|
+
assert "positive" in result[1].lower()
|
|
665
|
+
|
|
666
|
+
result = await strategy.deposit(main_token_amount=-10.0)
|
|
667
|
+
assert result[0] is False
|
|
668
|
+
assert "positive" in result[1].lower()
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def test_slippage_capped_at_max(strategy):
|
|
672
|
+
"""Test that slippage is capped at MAX_SLIPPAGE_TOLERANCE."""
|
|
673
|
+
strategy.MAX_SLIPPAGE_TOLERANCE = 0.03
|
|
674
|
+
strategy.swap_slippage_tolerance = 0.02
|
|
675
|
+
|
|
676
|
+
# With 3 retries at 2% base: 2%, 4%, 6% -> should be capped at 3%
|
|
677
|
+
# The actual slippage calculation happens in the method, we just verify the constant exists
|
|
678
|
+
assert hasattr(strategy, "MAX_SLIPPAGE_TOLERANCE")
|
|
679
|
+
assert strategy.MAX_SLIPPAGE_TOLERANCE == 0.03
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def test_price_staleness_threshold_exists(strategy):
|
|
683
|
+
"""Test that price staleness threshold is configured."""
|
|
684
|
+
assert hasattr(strategy, "PRICE_STALENESS_THRESHOLD")
|
|
685
|
+
assert strategy.PRICE_STALENESS_THRESHOLD > 0
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def test_min_leverage_gain_constant_exists(strategy):
|
|
689
|
+
"""Test that minimum leverage gain constant is configured."""
|
|
690
|
+
assert hasattr(strategy, "_MIN_LEVERAGE_GAIN_BPS")
|
|
691
|
+
assert strategy._MIN_LEVERAGE_GAIN_BPS == 50e-4 # 50 bps
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@pytest.mark.asyncio
|
|
695
|
+
async def test_leverage_calc_handles_high_cf_w(strategy, mock_adapter_responses):
|
|
696
|
+
"""Test that leverage calculation handles high collateral factors safely."""
|
|
697
|
+
strategy.MIN_HEALTH_FACTOR = 1.2
|
|
698
|
+
|
|
699
|
+
# This should return early without crashing when cf_w >= MIN_HEALTH_FACTOR
|
|
700
|
+
# Pass collateral_factors directly to avoid RPC call ordering issues
|
|
701
|
+
# collateral_factors = (cf_usdc, cf_wsteth)
|
|
702
|
+
result = await strategy._loop_wsteth(
|
|
703
|
+
wsteth_price=2000.0,
|
|
704
|
+
weth_price=2000.0,
|
|
705
|
+
current_borrowed_value=1000.0,
|
|
706
|
+
initial_leverage=1.5,
|
|
707
|
+
usdc_lend_value=1000.0,
|
|
708
|
+
wsteth_lend_value=500.0,
|
|
709
|
+
collateral_factors=(0.8, 1.3), # cf_u=0.8, cf_w=1.3 (higher than MIN_HF)
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
# Should return failure tuple instead of crashing
|
|
713
|
+
assert result[0] is False
|
|
714
|
+
assert result[2] == -1 # Error code
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@pytest.mark.asyncio
|
|
718
|
+
async def test_price_staleness_triggers_refresh(strategy, mock_adapter_responses):
|
|
719
|
+
"""Test that stale prices trigger a refresh."""
|
|
720
|
+
strategy.PRICE_STALENESS_THRESHOLD = 1 # 1 second for test
|
|
721
|
+
strategy._token_price_cache = {"test-token": 100.0}
|
|
722
|
+
strategy._token_price_timestamps = {"test-token": 0} # Very old timestamp
|
|
723
|
+
|
|
724
|
+
# Should refresh because timestamp is stale
|
|
725
|
+
await strategy._get_token_price("test-token")
|
|
726
|
+
|
|
727
|
+
# Should have called token adapter because cache was stale
|
|
728
|
+
strategy.token_adapter.get_token_price.assert_called_with("test-token")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
@pytest.mark.asyncio
|
|
732
|
+
async def test_partial_liquidate_prefers_wsteth_when_excess(strategy):
|
|
733
|
+
"""Partial liquidation should redeem wstETH first when collateral exceeds debt."""
|
|
734
|
+
|
|
735
|
+
# Token metadata
|
|
736
|
+
async def mock_get_token(token_id: str):
|
|
737
|
+
if token_id == USDC_TOKEN_ID:
|
|
738
|
+
return (True, {"decimals": 6})
|
|
739
|
+
if token_id == WSTETH_TOKEN_ID:
|
|
740
|
+
return (True, {"decimals": 18})
|
|
741
|
+
return (True, {"decimals": 18})
|
|
742
|
+
|
|
743
|
+
async def mock_get_price(token_id: str):
|
|
744
|
+
if token_id == WSTETH_TOKEN_ID:
|
|
745
|
+
return (True, {"current_price": 2000.0})
|
|
746
|
+
return (True, {"current_price": 1.0})
|
|
747
|
+
|
|
748
|
+
strategy.token_adapter.get_token = AsyncMock(side_effect=mock_get_token)
|
|
749
|
+
strategy.token_adapter.get_token_price = AsyncMock(side_effect=mock_get_price)
|
|
750
|
+
|
|
751
|
+
# Wallet balances (raw)
|
|
752
|
+
balances: dict[str, int] = {USDC_TOKEN_ID: 0, WSTETH_TOKEN_ID: 0}
|
|
753
|
+
|
|
754
|
+
async def mock_get_balance(*, token_id: str, wallet_address: str):
|
|
755
|
+
return (True, balances.get(token_id, 0))
|
|
756
|
+
|
|
757
|
+
strategy.balance_adapter.get_balance = AsyncMock(side_effect=mock_get_balance)
|
|
758
|
+
|
|
759
|
+
# Position snapshot: wstETH collateral > WETH debt
|
|
760
|
+
totals_usd = {
|
|
761
|
+
f"Base_{M_WSTETH}": 500.0,
|
|
762
|
+
f"Base_{M_USDC}": 1000.0,
|
|
763
|
+
f"Base_{WETH}": -200.0,
|
|
764
|
+
}
|
|
765
|
+
strategy._aggregate_positions = AsyncMock(return_value=({}, totals_usd))
|
|
766
|
+
|
|
767
|
+
# Collateral factors
|
|
768
|
+
strategy.moonwell_adapter.get_collateral_factor = AsyncMock(
|
|
769
|
+
return_value=(True, 0.8)
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
# mwstETH redemption metadata (1:1 exchange rate for test)
|
|
773
|
+
async def mock_max_withdrawable(*, mtoken: str):
|
|
774
|
+
return (
|
|
775
|
+
True,
|
|
776
|
+
{
|
|
777
|
+
"cTokens_raw": 10**30,
|
|
778
|
+
"exchangeRate_raw": 10**18,
|
|
779
|
+
"conversion_factor": 1.0,
|
|
780
|
+
},
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
strategy.moonwell_adapter.max_withdrawable_mtoken = AsyncMock(
|
|
784
|
+
side_effect=mock_max_withdrawable
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
async def mock_unlend(*, mtoken: str, amount: int):
|
|
788
|
+
if mtoken == M_WSTETH:
|
|
789
|
+
balances[WSTETH_TOKEN_ID] += int(amount)
|
|
790
|
+
return (True, "success")
|
|
791
|
+
|
|
792
|
+
strategy.moonwell_adapter.unlend = AsyncMock(side_effect=mock_unlend)
|
|
793
|
+
|
|
794
|
+
async def mock_swap(
|
|
795
|
+
from_token_id,
|
|
796
|
+
to_token_id,
|
|
797
|
+
from_address,
|
|
798
|
+
amount,
|
|
799
|
+
slippage=0.0,
|
|
800
|
+
strategy_name=None,
|
|
801
|
+
**_,
|
|
802
|
+
):
|
|
803
|
+
amt = int(amount)
|
|
804
|
+
# wstETH → USDC
|
|
805
|
+
balances[WSTETH_TOKEN_ID] -= amt
|
|
806
|
+
usd_out = (amt / 10**18) * 2000.0
|
|
807
|
+
usdc_out = int(usd_out * 10**6)
|
|
808
|
+
balances[USDC_TOKEN_ID] += usdc_out
|
|
809
|
+
return (True, {"to_amount": usdc_out})
|
|
810
|
+
|
|
811
|
+
strategy.brap_adapter.swap_from_token_ids = AsyncMock(side_effect=mock_swap)
|
|
812
|
+
|
|
813
|
+
# Also need to mock lend since partial_liquidate may try to re-lend leftover wstETH
|
|
814
|
+
strategy.moonwell_adapter.lend = AsyncMock(return_value=(True, "success"))
|
|
815
|
+
|
|
816
|
+
ok, msg = await strategy.partial_liquidate(usd_value=100.0)
|
|
817
|
+
assert ok
|
|
818
|
+
assert "available" in msg.lower()
|
|
819
|
+
|
|
820
|
+
# Should have redeemed mwstETH and swapped to USDC
|
|
821
|
+
assert strategy.moonwell_adapter.unlend.call_count == 1
|
|
822
|
+
assert strategy.moonwell_adapter.unlend.call_args.kwargs["mtoken"] == M_WSTETH
|
|
823
|
+
assert strategy.brap_adapter.swap_from_token_ids.call_count >= 1
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
@pytest.mark.asyncio
|
|
827
|
+
async def test_partial_liquidate_uses_usdc_collateral_when_no_wsteth_excess(strategy):
|
|
828
|
+
"""If wstETH collateral doesn't exceed debt, partial liquidation should redeem USDC collateral."""
|
|
829
|
+
# Token metadata
|
|
830
|
+
strategy.token_adapter.get_token = AsyncMock(
|
|
831
|
+
side_effect=lambda token_id: (
|
|
832
|
+
True,
|
|
833
|
+
{"decimals": 6} if token_id == USDC_TOKEN_ID else {"decimals": 18},
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
strategy.token_adapter.get_token_price = AsyncMock(
|
|
837
|
+
return_value=(True, {"current_price": 1.0})
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
balances: dict[str, int] = {USDC_TOKEN_ID: 0}
|
|
841
|
+
|
|
842
|
+
async def mock_get_balance(*, token_id: str, wallet_address: str):
|
|
843
|
+
return (True, balances.get(token_id, 0))
|
|
844
|
+
|
|
845
|
+
strategy.balance_adapter.get_balance = AsyncMock(side_effect=mock_get_balance)
|
|
846
|
+
|
|
847
|
+
totals_usd = {
|
|
848
|
+
f"Base_{M_WSTETH}": 100.0, # <= debt
|
|
849
|
+
f"Base_{M_USDC}": 500.0,
|
|
850
|
+
f"Base_{WETH}": -200.0,
|
|
851
|
+
}
|
|
852
|
+
strategy._aggregate_positions = AsyncMock(return_value=({}, totals_usd))
|
|
853
|
+
|
|
854
|
+
strategy.moonwell_adapter.get_collateral_factor = AsyncMock(
|
|
855
|
+
return_value=(True, 0.8)
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
async def mock_max_withdrawable(*, mtoken: str):
|
|
859
|
+
return (
|
|
860
|
+
True,
|
|
861
|
+
{
|
|
862
|
+
"cTokens_raw": 10**30,
|
|
863
|
+
"exchangeRate_raw": 10**18,
|
|
864
|
+
"conversion_factor": 1.0,
|
|
865
|
+
},
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
strategy.moonwell_adapter.max_withdrawable_mtoken = AsyncMock(
|
|
869
|
+
side_effect=mock_max_withdrawable
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
async def mock_unlend(*, mtoken: str, amount: int):
|
|
873
|
+
if mtoken == M_USDC:
|
|
874
|
+
balances[USDC_TOKEN_ID] += int(amount)
|
|
875
|
+
return (True, "success")
|
|
876
|
+
|
|
877
|
+
strategy.moonwell_adapter.unlend = AsyncMock(side_effect=mock_unlend)
|
|
878
|
+
|
|
879
|
+
ok, msg = await strategy.partial_liquidate(usd_value=50.0)
|
|
880
|
+
assert ok
|
|
881
|
+
assert "available" in msg.lower()
|
|
882
|
+
|
|
883
|
+
# Should redeem mUSDC and not need a swap
|
|
884
|
+
assert strategy.moonwell_adapter.unlend.call_count == 1
|
|
885
|
+
assert strategy.moonwell_adapter.unlend.call_args.kwargs["mtoken"] == M_USDC
|
|
886
|
+
assert not strategy.brap_adapter.swap_from_token_ids.called
|