wayfinder-paths 0.1.19__py3-none-any.whl → 0.1.21__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wayfinder-paths might be problematic. Click here for more details.
- wayfinder_paths/__init__.py +0 -2
- wayfinder_paths/adapters/balance_adapter/README.md +59 -45
- wayfinder_paths/adapters/balance_adapter/adapter.py +1 -22
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -14
- wayfinder_paths/adapters/brap_adapter/README.md +61 -184
- wayfinder_paths/adapters/brap_adapter/__init__.py +0 -4
- wayfinder_paths/adapters/brap_adapter/adapter.py +1 -148
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +0 -15
- wayfinder_paths/adapters/hyperlend_adapter/__init__.py +0 -4
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +1 -10
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +0 -17
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +3 -312
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +1 -71
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +0 -57
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +0 -17
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +2 -42
- wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +1 -9
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +15 -47
- wayfinder_paths/adapters/hyperliquid_adapter/utils.py +0 -7
- wayfinder_paths/adapters/ledger_adapter/README.md +54 -74
- wayfinder_paths/adapters/ledger_adapter/__init__.py +0 -4
- wayfinder_paths/adapters/ledger_adapter/adapter.py +0 -106
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +0 -12
- wayfinder_paths/adapters/moonwell_adapter/README.md +67 -106
- wayfinder_paths/adapters/moonwell_adapter/__init__.py +0 -4
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +10 -122
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +84 -83
- wayfinder_paths/adapters/pool_adapter/README.md +30 -51
- wayfinder_paths/adapters/pool_adapter/__init__.py +0 -4
- wayfinder_paths/adapters/pool_adapter/adapter.py +0 -19
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +0 -8
- wayfinder_paths/adapters/token_adapter/README.md +41 -49
- wayfinder_paths/adapters/token_adapter/adapter.py +0 -32
- wayfinder_paths/adapters/token_adapter/test_adapter.py +1 -12
- wayfinder_paths/conftest.py +0 -8
- wayfinder_paths/core/__init__.py +0 -2
- wayfinder_paths/core/adapters/BaseAdapter.py +0 -22
- wayfinder_paths/core/adapters/__init__.py +0 -5
- wayfinder_paths/core/adapters/models.py +0 -5
- wayfinder_paths/core/analytics/__init__.py +0 -2
- wayfinder_paths/core/analytics/bootstrap.py +0 -16
- wayfinder_paths/core/analytics/stats.py +0 -7
- wayfinder_paths/core/analytics/test_analytics.py +5 -34
- wayfinder_paths/core/clients/BRAPClient.py +0 -35
- wayfinder_paths/core/clients/ClientManager.py +0 -51
- wayfinder_paths/core/clients/HyperlendClient.py +0 -77
- wayfinder_paths/core/clients/LedgerClient.py +2 -122
- wayfinder_paths/core/clients/PoolClient.py +0 -2
- wayfinder_paths/core/clients/TokenClient.py +0 -39
- wayfinder_paths/core/clients/WalletClient.py +0 -15
- wayfinder_paths/core/clients/WayfinderClient.py +0 -24
- wayfinder_paths/core/clients/__init__.py +0 -4
- wayfinder_paths/core/clients/protocols.py +25 -98
- wayfinder_paths/core/config.py +0 -24
- wayfinder_paths/core/constants/__init__.py +0 -7
- wayfinder_paths/core/constants/base.py +2 -9
- wayfinder_paths/core/constants/erc20_abi.py +0 -5
- wayfinder_paths/core/constants/hyperlend_abi.py +0 -7
- wayfinder_paths/core/constants/moonwell_abi.py +0 -35
- wayfinder_paths/core/engine/StrategyJob.py +0 -32
- wayfinder_paths/core/strategies/Strategy.py +0 -99
- wayfinder_paths/core/strategies/__init__.py +0 -2
- wayfinder_paths/core/utils/__init__.py +0 -1
- wayfinder_paths/core/utils/evm_helpers.py +0 -50
- wayfinder_paths/core/utils/{erc20_service.py → tokens.py} +25 -21
- wayfinder_paths/core/utils/transaction.py +0 -1
- wayfinder_paths/run_strategy.py +0 -46
- wayfinder_paths/scripts/create_strategy.py +0 -17
- wayfinder_paths/scripts/make_wallets.py +1 -4
- wayfinder_paths/strategies/basis_trading_strategy/README.md +71 -163
- wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +0 -24
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +36 -400
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +15 -64
- wayfinder_paths/strategies/basis_trading_strategy/types.py +0 -4
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +65 -56
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +4 -27
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +0 -10
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +71 -72
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +23 -227
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +120 -113
- wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +64 -59
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +4 -44
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +2 -35
- wayfinder_paths/templates/adapter/README.md +107 -46
- wayfinder_paths/templates/adapter/adapter.py +0 -9
- wayfinder_paths/templates/adapter/test_adapter.py +0 -19
- wayfinder_paths/templates/strategy/README.md +113 -59
- wayfinder_paths/templates/strategy/strategy.py +0 -22
- wayfinder_paths/templates/strategy/test_strategy.py +0 -28
- wayfinder_paths/tests/test_test_coverage.py +2 -12
- wayfinder_paths/tests/test_utils.py +1 -31
- wayfinder_paths-0.1.21.dist-info/METADATA +355 -0
- wayfinder_paths-0.1.21.dist-info/RECORD +129 -0
- {wayfinder_paths-0.1.19.dist-info → wayfinder_paths-0.1.21.dist-info}/WHEEL +1 -1
- wayfinder_paths/core/adapters/base.py +0 -5
- wayfinder_paths-0.1.19.dist-info/METADATA +0 -592
- wayfinder_paths-0.1.19.dist-info/RECORD +0 -130
- {wayfinder_paths-0.1.19.dist-info → wayfinder_paths-0.1.21.dist-info}/LICENSE +0 -0
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"""Test template for Moonwell wstETH Loop Strategy."""
|
|
2
|
-
|
|
3
1
|
from pathlib import Path
|
|
4
2
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
5
3
|
|
|
@@ -25,7 +23,6 @@ from wayfinder_paths.tests.test_utils import (
|
|
|
25
23
|
|
|
26
24
|
@pytest.fixture
|
|
27
25
|
def strategy():
|
|
28
|
-
"""Create a strategy instance for testing with minimal config."""
|
|
29
26
|
mock_config = {
|
|
30
27
|
"main_wallet": {"address": "0x1234567890123456789012345678901234567890"},
|
|
31
28
|
"strategy_wallet": {"address": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"},
|
|
@@ -39,11 +36,9 @@ def strategy():
|
|
|
39
36
|
config=mock_config,
|
|
40
37
|
main_wallet=mock_config["main_wallet"],
|
|
41
38
|
strategy_wallet=mock_config["strategy_wallet"],
|
|
42
|
-
simulation=True,
|
|
43
39
|
)
|
|
44
40
|
# Manually set attributes that would be set in __init__
|
|
45
41
|
s.config = mock_config
|
|
46
|
-
s.simulation = True
|
|
47
42
|
s._token_info_cache = {}
|
|
48
43
|
s._token_price_cache = {}
|
|
49
44
|
s._token_price_timestamps = {}
|
|
@@ -61,7 +56,6 @@ def strategy():
|
|
|
61
56
|
|
|
62
57
|
@pytest.fixture
|
|
63
58
|
def mock_adapter_responses(strategy):
|
|
64
|
-
"""Set up mock responses for adapter calls."""
|
|
65
59
|
# Mock balance adapter
|
|
66
60
|
strategy.balance_adapter.get_balance = AsyncMock(return_value=(True, 1000000))
|
|
67
61
|
strategy.balance_adapter.move_from_main_wallet_to_strategy_wallet = AsyncMock(
|
|
@@ -119,7 +113,6 @@ def mock_adapter_responses(strategy):
|
|
|
119
113
|
@pytest.mark.asyncio
|
|
120
114
|
@pytest.mark.smoke
|
|
121
115
|
async def test_smoke(strategy, mock_adapter_responses):
|
|
122
|
-
"""REQUIRED: Basic smoke test - verifies strategy lifecycle."""
|
|
123
116
|
examples = load_strategy_examples(Path(__file__))
|
|
124
117
|
smoke_data = examples["smoke"]
|
|
125
118
|
|
|
@@ -152,7 +145,6 @@ async def test_smoke(strategy, mock_adapter_responses):
|
|
|
152
145
|
assert isinstance(ok, bool)
|
|
153
146
|
assert isinstance(msg, str)
|
|
154
147
|
|
|
155
|
-
# Update test
|
|
156
148
|
with patch.object(strategy, "update", new_callable=AsyncMock) as mock_update:
|
|
157
149
|
mock_update.return_value = (True, "success")
|
|
158
150
|
ok, msg = await strategy.update(**smoke_data.get("update", {}))
|
|
@@ -169,7 +161,6 @@ async def test_smoke(strategy, mock_adapter_responses):
|
|
|
169
161
|
|
|
170
162
|
@pytest.mark.asyncio
|
|
171
163
|
async def test_canonical_usage(strategy, mock_adapter_responses):
|
|
172
|
-
"""REQUIRED: Test canonical usage examples from examples.json (minimum)."""
|
|
173
164
|
examples = load_strategy_examples(Path(__file__))
|
|
174
165
|
canonical = get_canonical_examples(examples)
|
|
175
166
|
|
|
@@ -216,7 +207,6 @@ async def test_canonical_usage(strategy, mock_adapter_responses):
|
|
|
216
207
|
|
|
217
208
|
@pytest.mark.asyncio
|
|
218
209
|
async def test_status_returns_status_dict(strategy, mock_adapter_responses):
|
|
219
|
-
"""Test that _status returns a proper StatusDict."""
|
|
220
210
|
snap = MagicMock()
|
|
221
211
|
snap.totals_usd = {}
|
|
222
212
|
snap.ltv = 0.5
|
|
@@ -230,7 +220,7 @@ async def test_status_returns_status_dict(strategy, mock_adapter_responses):
|
|
|
230
220
|
with patch.object(
|
|
231
221
|
strategy, "_get_gas_balance", new_callable=AsyncMock
|
|
232
222
|
) as mock_gas:
|
|
233
|
-
mock_gas.return_value = 100000000000000000
|
|
223
|
+
mock_gas.return_value = 100000000000000000
|
|
234
224
|
with patch.object(
|
|
235
225
|
strategy, "get_peg_diff", new_callable=AsyncMock
|
|
236
226
|
) as mock_peg:
|
|
@@ -251,7 +241,6 @@ async def test_status_returns_status_dict(strategy, mock_adapter_responses):
|
|
|
251
241
|
|
|
252
242
|
@pytest.mark.asyncio
|
|
253
243
|
async def test_policies_returns_list(strategy):
|
|
254
|
-
"""Test that policies returns a non-empty list."""
|
|
255
244
|
# Mock the policy functions to avoid ABI fetching
|
|
256
245
|
with (
|
|
257
246
|
patch(
|
|
@@ -292,7 +281,6 @@ async def test_policies_returns_list(strategy):
|
|
|
292
281
|
|
|
293
282
|
@pytest.mark.asyncio
|
|
294
283
|
async def test_quote_returns_apy_info(strategy, mock_adapter_responses):
|
|
295
|
-
"""Test that quote returns APY information."""
|
|
296
284
|
with patch("httpx.AsyncClient") as mock_client:
|
|
297
285
|
mock_response = MagicMock()
|
|
298
286
|
mock_response.json.return_value = {"data": {"smaApr": 3.5}}
|
|
@@ -310,8 +298,6 @@ async def test_quote_returns_apy_info(strategy, mock_adapter_responses):
|
|
|
310
298
|
|
|
311
299
|
|
|
312
300
|
def test_max_safe_f_calculates_correctly(strategy):
|
|
313
|
-
"""Test that _max_safe_F calculates the depeg-aware leverage limit correctly."""
|
|
314
|
-
# Set up strategy with MAX_DEPEG = 0.01 (1%)
|
|
315
301
|
strategy.MAX_DEPEG = 0.01
|
|
316
302
|
|
|
317
303
|
# With cf_w = 0.8, a = 0.99
|
|
@@ -322,7 +308,6 @@ def test_max_safe_f_calculates_correctly(strategy):
|
|
|
322
308
|
|
|
323
309
|
|
|
324
310
|
def test_max_safe_f_with_zero_collateral_factor(strategy):
|
|
325
|
-
"""Test _max_safe_F with zero collateral factor."""
|
|
326
311
|
strategy.MAX_DEPEG = 0.01
|
|
327
312
|
result = strategy._max_safe_F(0.0)
|
|
328
313
|
# F_max = 1 / (1 + 0 * anything) = 1.0
|
|
@@ -333,15 +318,18 @@ def test_max_safe_f_with_zero_collateral_factor(strategy):
|
|
|
333
318
|
async def test_swap_with_retries_succeeds_first_attempt(
|
|
334
319
|
strategy, mock_adapter_responses
|
|
335
320
|
):
|
|
336
|
-
"""Test that _swap_with_retries succeeds on first attempt."""
|
|
337
321
|
strategy.max_swap_retries = 3
|
|
338
322
|
strategy.swap_slippage_tolerance = 0.005
|
|
339
323
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
324
|
+
with patch.object(
|
|
325
|
+
strategy, "_get_balance_raw", new_callable=AsyncMock
|
|
326
|
+
) as mock_balance:
|
|
327
|
+
mock_balance.return_value = 10**18
|
|
328
|
+
result = await strategy._swap_with_retries(
|
|
329
|
+
from_token_id="usd-coin-base",
|
|
330
|
+
to_token_id="l2-standard-bridged-weth-base-base",
|
|
331
|
+
amount=1000000,
|
|
332
|
+
)
|
|
345
333
|
|
|
346
334
|
assert result is not None
|
|
347
335
|
assert "to_amount" in result
|
|
@@ -352,7 +340,6 @@ async def test_swap_with_retries_succeeds_first_attempt(
|
|
|
352
340
|
async def test_swap_with_retries_succeeds_on_second_attempt(
|
|
353
341
|
strategy, mock_adapter_responses
|
|
354
342
|
):
|
|
355
|
-
"""Test that _swap_with_retries retries and succeeds."""
|
|
356
343
|
strategy.max_swap_retries = 3
|
|
357
344
|
strategy.swap_slippage_tolerance = 0.005
|
|
358
345
|
|
|
@@ -364,7 +351,13 @@ async def test_swap_with_retries_succeeds_on_second_attempt(
|
|
|
364
351
|
]
|
|
365
352
|
)
|
|
366
353
|
|
|
367
|
-
with
|
|
354
|
+
with (
|
|
355
|
+
patch.object(
|
|
356
|
+
strategy, "_get_balance_raw", new_callable=AsyncMock
|
|
357
|
+
) as mock_balance,
|
|
358
|
+
patch("asyncio.sleep", new_callable=AsyncMock),
|
|
359
|
+
):
|
|
360
|
+
mock_balance.return_value = 10**18
|
|
368
361
|
result = await strategy._swap_with_retries(
|
|
369
362
|
from_token_id="usd-coin-base",
|
|
370
363
|
to_token_id="l2-standard-bridged-weth-base-base",
|
|
@@ -378,7 +371,6 @@ async def test_swap_with_retries_succeeds_on_second_attempt(
|
|
|
378
371
|
|
|
379
372
|
@pytest.mark.asyncio
|
|
380
373
|
async def test_swap_with_retries_fails_all_attempts(strategy, mock_adapter_responses):
|
|
381
|
-
"""Test that _swap_with_retries returns None after all attempts fail."""
|
|
382
374
|
strategy.max_swap_retries = 3
|
|
383
375
|
strategy.swap_slippage_tolerance = 0.005
|
|
384
376
|
|
|
@@ -386,7 +378,13 @@ async def test_swap_with_retries_fails_all_attempts(strategy, mock_adapter_respo
|
|
|
386
378
|
side_effect=Exception("Swap failed")
|
|
387
379
|
)
|
|
388
380
|
|
|
389
|
-
with
|
|
381
|
+
with (
|
|
382
|
+
patch.object(
|
|
383
|
+
strategy, "_get_balance_raw", new_callable=AsyncMock
|
|
384
|
+
) as mock_balance,
|
|
385
|
+
patch("asyncio.sleep", new_callable=AsyncMock),
|
|
386
|
+
):
|
|
387
|
+
mock_balance.return_value = 10**18
|
|
390
388
|
result = await strategy._swap_with_retries(
|
|
391
389
|
from_token_id="usd-coin-base",
|
|
392
390
|
to_token_id="l2-standard-bridged-weth-base-base",
|
|
@@ -401,7 +399,6 @@ async def test_swap_with_retries_fails_all_attempts(strategy, mock_adapter_respo
|
|
|
401
399
|
async def test_swap_with_retries_aborts_on_unknown_outcome(
|
|
402
400
|
strategy, mock_adapter_responses
|
|
403
401
|
):
|
|
404
|
-
"""Swap retries must abort (no retry) when the transaction outcome is unknown."""
|
|
405
402
|
strategy.max_swap_retries = 3
|
|
406
403
|
strategy.brap_adapter.swap_from_token_ids = AsyncMock(
|
|
407
404
|
return_value=(
|
|
@@ -410,19 +407,22 @@ async def test_swap_with_retries_aborts_on_unknown_outcome(
|
|
|
410
407
|
)
|
|
411
408
|
)
|
|
412
409
|
|
|
413
|
-
with
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
410
|
+
with patch.object(
|
|
411
|
+
strategy, "_get_balance_raw", new_callable=AsyncMock
|
|
412
|
+
) as mock_balance:
|
|
413
|
+
mock_balance.return_value = 10**18
|
|
414
|
+
with pytest.raises(SwapOutcomeUnknownError):
|
|
415
|
+
await strategy._swap_with_retries(
|
|
416
|
+
from_token_id=USDC_TOKEN_ID,
|
|
417
|
+
to_token_id=WETH_TOKEN_ID,
|
|
418
|
+
amount=1000000,
|
|
419
|
+
)
|
|
419
420
|
|
|
420
421
|
assert strategy.brap_adapter.swap_from_token_ids.call_count == 1
|
|
421
422
|
|
|
422
423
|
|
|
423
424
|
@pytest.mark.asyncio
|
|
424
425
|
async def test_post_run_guard_no_action_when_delta_ok(strategy, mock_adapter_responses):
|
|
425
|
-
"""Post-run guard should do nothing when delta is within tolerance."""
|
|
426
426
|
snap = MagicMock()
|
|
427
427
|
snap.wallet_wsteth = 0
|
|
428
428
|
snap.usdc_supplied = 0
|
|
@@ -472,7 +472,6 @@ async def test_post_run_guard_no_action_when_delta_ok(strategy, mock_adapter_res
|
|
|
472
472
|
async def test_post_run_guard_restores_delta_via_reconcile(
|
|
473
473
|
strategy, mock_adapter_responses
|
|
474
474
|
):
|
|
475
|
-
"""Post-run guard should attempt wallet reconcile in operate mode when net short."""
|
|
476
475
|
snap1 = MagicMock()
|
|
477
476
|
snap1.wallet_wsteth = 0
|
|
478
477
|
snap1.usdc_supplied = 0
|
|
@@ -529,7 +528,6 @@ async def test_post_run_guard_restores_delta_via_reconcile(
|
|
|
529
528
|
async def test_post_run_guard_delevers_to_delta_when_reconcile_insufficient(
|
|
530
529
|
strategy, mock_adapter_responses
|
|
531
530
|
):
|
|
532
|
-
"""Post-run guard should delever debt when still net short after reconcile."""
|
|
533
531
|
snap1 = MagicMock()
|
|
534
532
|
snap1.wallet_wsteth = 0
|
|
535
533
|
snap1.usdc_supplied = 0
|
|
@@ -554,7 +552,7 @@ async def test_post_run_guard_delevers_to_delta_when_reconcile_insufficient(
|
|
|
554
552
|
snap3.wallet_wsteth = 0
|
|
555
553
|
snap3.usdc_supplied = 0
|
|
556
554
|
snap3.wsteth_supplied = 0
|
|
557
|
-
snap3.weth_debt = int(0.005 * 10**18)
|
|
555
|
+
snap3.weth_debt = int(0.005 * 10**18)
|
|
558
556
|
snap3.debt_usd = 12.0
|
|
559
557
|
snap3.hf = 1.3
|
|
560
558
|
snap3.capacity_usd = 1000.0
|
|
@@ -602,7 +600,6 @@ async def test_post_run_guard_delevers_to_delta_when_reconcile_insufficient(
|
|
|
602
600
|
async def test_atomic_deposit_iteration_swaps_from_eth_when_borrow_surfaces_as_eth(
|
|
603
601
|
strategy, mock_adapter_responses
|
|
604
602
|
):
|
|
605
|
-
"""Borrowed WETH can surface as native ETH; iteration should wrap ETH→WETH then swap WETH→wstETH."""
|
|
606
603
|
# Ensure gas reserve exists so we don't drain to 0
|
|
607
604
|
strategy.WRAP_GAS_RESERVE = 0.0014
|
|
608
605
|
|
|
@@ -615,12 +612,8 @@ async def test_atomic_deposit_iteration_swaps_from_eth_when_borrow_surfaces_as_e
|
|
|
615
612
|
WSTETH_TOKEN_ID: 0,
|
|
616
613
|
}
|
|
617
614
|
|
|
618
|
-
async def
|
|
619
|
-
return
|
|
620
|
-
|
|
621
|
-
strategy.balance_adapter.get_balance = AsyncMock(
|
|
622
|
-
side_effect=get_balance_side_effect
|
|
623
|
-
)
|
|
615
|
+
async def get_balance_raw_side_effect(*, token_id: str, wallet_address: str, **_):
|
|
616
|
+
return balances.get(token_id, 0)
|
|
624
617
|
|
|
625
618
|
async def borrow_side_effect(*, mtoken: str, amount: int):
|
|
626
619
|
# Borrow shows up as native ETH (simulates on-chain behavior)
|
|
@@ -650,7 +643,11 @@ async def test_atomic_deposit_iteration_swaps_from_eth_when_borrow_surfaces_as_e
|
|
|
650
643
|
|
|
651
644
|
strategy._swap_with_retries = AsyncMock(side_effect=swap_side_effect)
|
|
652
645
|
|
|
653
|
-
|
|
646
|
+
with patch.object(
|
|
647
|
+
strategy, "_get_balance_raw", new_callable=AsyncMock
|
|
648
|
+
) as mock_balance:
|
|
649
|
+
mock_balance.side_effect = get_balance_raw_side_effect
|
|
650
|
+
lent = await strategy._atomic_deposit_iteration(borrow_amt_wei)
|
|
654
651
|
|
|
655
652
|
assert lent == 123
|
|
656
653
|
strategy.moonwell_adapter.borrow.assert_called_once_with(
|
|
@@ -664,46 +661,52 @@ async def test_atomic_deposit_iteration_swaps_from_eth_when_borrow_surfaces_as_e
|
|
|
664
661
|
async def test_reconcile_wallet_into_position_uses_eth_inventory(
|
|
665
662
|
strategy, mock_adapter_responses
|
|
666
663
|
):
|
|
667
|
-
"""If debt exists but wstETH collateral is missing, use wallet ETH to complete the loop."""
|
|
668
|
-
|
|
669
|
-
async def get_pos_side_effect(*, mtoken: str):
|
|
670
|
-
if mtoken == M_USDC:
|
|
671
|
-
return (True, {"underlying_balance": 0, "borrow_balance": 0})
|
|
672
|
-
if mtoken == M_WSTETH:
|
|
673
|
-
return (True, {"underlying_balance": 0, "borrow_balance": 0})
|
|
674
|
-
if mtoken == M_WETH:
|
|
675
|
-
return (True, {"underlying_balance": 0, "borrow_balance": 5 * 10**18})
|
|
676
|
-
return (True, {})
|
|
677
|
-
|
|
678
|
-
strategy.moonwell_adapter.get_pos = AsyncMock(side_effect=get_pos_side_effect)
|
|
679
|
-
|
|
680
664
|
balances: dict[str, int] = {
|
|
681
665
|
ETH_TOKEN_ID: 10 * 10**18,
|
|
682
666
|
WETH_TOKEN_ID: 0,
|
|
683
667
|
WSTETH_TOKEN_ID: 0,
|
|
684
668
|
}
|
|
685
669
|
|
|
686
|
-
|
|
687
|
-
|
|
670
|
+
snap = MagicMock()
|
|
671
|
+
snap.wallet_wsteth = 0
|
|
672
|
+
snap.wallet_weth = 0
|
|
673
|
+
snap.wsteth_dec = 18
|
|
674
|
+
snap.weth_dec = 18
|
|
675
|
+
snap.wsteth_price = 2000.0
|
|
676
|
+
snap.weth_price = 2000.0
|
|
677
|
+
snap.weth_debt = 5 * 10**18
|
|
678
|
+
snap.debt_usd = 10000.0
|
|
679
|
+
snap.eth_usable_wei = 10 * 10**18
|
|
680
|
+
snap.totals_usd = {f"Base_{M_WSTETH}": 0.0, f"Base_{WETH}": -10000.0}
|
|
688
681
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
)
|
|
682
|
+
async def get_balance_raw_side_effect(*, token_id: str, wallet_address: str, **_):
|
|
683
|
+
return balances.get(token_id, 0)
|
|
692
684
|
|
|
693
685
|
async def swap_side_effect(
|
|
694
686
|
*, from_token_id: str, to_token_id: str, amount: int, **_
|
|
695
687
|
):
|
|
688
|
+
# _reconcile_wallet_into_position calls _swap_with_retries with ETH_TOKEN_ID
|
|
696
689
|
assert from_token_id == ETH_TOKEN_ID
|
|
697
690
|
assert to_token_id == WSTETH_TOKEN_ID
|
|
698
691
|
balances[WSTETH_TOKEN_ID] += 7 * 10**18
|
|
699
|
-
return {"to_amount": 7 * 10**18}
|
|
692
|
+
return {"to_amount": 7 * 10**18, "block_number": 12345}
|
|
700
693
|
|
|
701
694
|
strategy._swap_with_retries = AsyncMock(side_effect=swap_side_effect)
|
|
702
695
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
696
|
+
with (
|
|
697
|
+
patch.object(
|
|
698
|
+
strategy, "_accounting_snapshot", new_callable=AsyncMock
|
|
699
|
+
) as mock_snap,
|
|
700
|
+
patch.object(
|
|
701
|
+
strategy, "_get_balance_raw", new_callable=AsyncMock
|
|
702
|
+
) as mock_balance,
|
|
703
|
+
):
|
|
704
|
+
mock_snap.return_value = (snap, (0.8, 0.8))
|
|
705
|
+
mock_balance.side_effect = get_balance_raw_side_effect
|
|
706
|
+
success, msg = await strategy._reconcile_wallet_into_position(
|
|
707
|
+
collateral_factors=(0.8, 0.8),
|
|
708
|
+
max_batch_usd=100000.0,
|
|
709
|
+
)
|
|
707
710
|
|
|
708
711
|
assert success is True
|
|
709
712
|
assert strategy._swap_with_retries.called
|
|
@@ -712,7 +715,6 @@ async def test_reconcile_wallet_into_position_uses_eth_inventory(
|
|
|
712
715
|
|
|
713
716
|
@pytest.mark.asyncio
|
|
714
717
|
async def test_sweep_token_balances_no_tokens(strategy, mock_adapter_responses):
|
|
715
|
-
"""Test that _sweep_token_balances handles empty wallet."""
|
|
716
718
|
# All balances are 0
|
|
717
719
|
strategy.balance_adapter.get_balance = AsyncMock(return_value=(True, 0))
|
|
718
720
|
|
|
@@ -726,29 +728,30 @@ async def test_sweep_token_balances_no_tokens(strategy, mock_adapter_responses):
|
|
|
726
728
|
|
|
727
729
|
@pytest.mark.asyncio
|
|
728
730
|
async def test_sweep_token_balances_sweeps_tokens(strategy, mock_adapter_responses):
|
|
729
|
-
"""Test that _sweep_token_balances converts dust tokens."""
|
|
730
731
|
strategy.min_withdraw_usd = 1.0
|
|
731
732
|
|
|
732
733
|
# Mock balance returns (has some WETH dust)
|
|
733
|
-
def
|
|
734
|
+
async def get_balance_raw_side_effect(*, token_id: str, wallet_address: str, **_):
|
|
734
735
|
if "weth" in token_id.lower():
|
|
735
|
-
return
|
|
736
|
-
return
|
|
737
|
-
|
|
738
|
-
strategy.balance_adapter.get_balance = AsyncMock(side_effect=balance_side_effect)
|
|
736
|
+
return 100 * 10**18
|
|
737
|
+
return 0
|
|
739
738
|
|
|
740
739
|
# Mock price (high enough to trigger sweep)
|
|
741
740
|
strategy.token_adapter.get_token_price = AsyncMock(
|
|
742
741
|
return_value=(True, {"current_price": 2000.0})
|
|
743
742
|
)
|
|
744
743
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
744
|
+
with patch.object(
|
|
745
|
+
strategy, "_get_balance_raw", new_callable=AsyncMock
|
|
746
|
+
) as mock_balance:
|
|
747
|
+
mock_balance.side_effect = get_balance_raw_side_effect
|
|
748
|
+
success, msg = await strategy._sweep_token_balances(
|
|
749
|
+
target_token_id="usd-coin-base",
|
|
750
|
+
exclude=set(),
|
|
751
|
+
)
|
|
749
752
|
|
|
750
753
|
assert success is True
|
|
751
|
-
# Should have called swap
|
|
754
|
+
# Should have called swap via _swap_with_retries
|
|
752
755
|
strategy.brap_adapter.swap_from_token_ids.assert_called()
|
|
753
756
|
|
|
754
757
|
|
|
@@ -757,7 +760,6 @@ async def test_sweep_token_balances_sweeps_tokens(strategy, mock_adapter_respons
|
|
|
757
760
|
|
|
758
761
|
@pytest.mark.asyncio
|
|
759
762
|
async def test_deposit_rejects_zero_amount(strategy):
|
|
760
|
-
"""Test that deposit rejects zero or negative amounts."""
|
|
761
763
|
result = await strategy.deposit(main_token_amount=0.0)
|
|
762
764
|
assert result[0] is False
|
|
763
765
|
assert "positive" in result[1].lower()
|
|
@@ -768,26 +770,22 @@ async def test_deposit_rejects_zero_amount(strategy):
|
|
|
768
770
|
|
|
769
771
|
|
|
770
772
|
def test_slippage_capped_at_max(strategy):
|
|
771
|
-
"""Test that slippage is capped at MAX_SLIPPAGE_TOLERANCE."""
|
|
772
773
|
strategy.MAX_SLIPPAGE_TOLERANCE = 0.03
|
|
773
774
|
strategy.swap_slippage_tolerance = 0.02
|
|
774
775
|
|
|
775
776
|
# With 3 retries at 2% base: 2%, 4%, 6% -> should be capped at 3%
|
|
776
|
-
# The actual slippage calculation happens in the method, we just verify the constant exists
|
|
777
777
|
assert hasattr(strategy, "MAX_SLIPPAGE_TOLERANCE")
|
|
778
778
|
assert strategy.MAX_SLIPPAGE_TOLERANCE == 0.03
|
|
779
779
|
|
|
780
780
|
|
|
781
781
|
def test_price_staleness_threshold_exists(strategy):
|
|
782
|
-
"""Test that price staleness threshold is configured."""
|
|
783
782
|
assert hasattr(strategy, "PRICE_STALENESS_THRESHOLD")
|
|
784
783
|
assert strategy.PRICE_STALENESS_THRESHOLD > 0
|
|
785
784
|
|
|
786
785
|
|
|
787
786
|
def test_min_leverage_gain_constant_exists(strategy):
|
|
788
|
-
"""Test that minimum leverage gain constant is configured."""
|
|
789
787
|
assert hasattr(strategy, "_MIN_LEVERAGE_GAIN_BPS")
|
|
790
|
-
assert strategy._MIN_LEVERAGE_GAIN_BPS == 50e-4
|
|
788
|
+
assert strategy._MIN_LEVERAGE_GAIN_BPS == 50e-4
|
|
791
789
|
|
|
792
790
|
|
|
793
791
|
@pytest.mark.asyncio
|
|
@@ -848,7 +846,6 @@ async def test_withdraw_runs_post_run_guard_only_on_failure(
|
|
|
848
846
|
|
|
849
847
|
@pytest.mark.asyncio
|
|
850
848
|
async def test_leverage_calc_handles_high_cf_w(strategy, mock_adapter_responses):
|
|
851
|
-
"""Test that leverage calculation handles high collateral factors safely."""
|
|
852
849
|
strategy.MIN_HEALTH_FACTOR = 1.2
|
|
853
850
|
|
|
854
851
|
# This should return early without crashing when cf_w >= MIN_HEALTH_FACTOR
|
|
@@ -861,20 +858,19 @@ async def test_leverage_calc_handles_high_cf_w(strategy, mock_adapter_responses)
|
|
|
861
858
|
initial_leverage=1.5,
|
|
862
859
|
usdc_lend_value=1000.0,
|
|
863
860
|
wsteth_lend_value=500.0,
|
|
864
|
-
collateral_factors=(0.8, 1.3),
|
|
861
|
+
collateral_factors=(0.8, 1.3),
|
|
865
862
|
)
|
|
866
863
|
|
|
867
864
|
# Should return failure tuple instead of crashing
|
|
868
865
|
assert result[0] is False
|
|
869
|
-
assert result[2] == -1
|
|
866
|
+
assert result[2] == -1
|
|
870
867
|
|
|
871
868
|
|
|
872
869
|
@pytest.mark.asyncio
|
|
873
870
|
async def test_price_staleness_triggers_refresh(strategy, mock_adapter_responses):
|
|
874
|
-
|
|
875
|
-
strategy.PRICE_STALENESS_THRESHOLD = 1 # 1 second for test
|
|
871
|
+
strategy.PRICE_STALENESS_THRESHOLD = 1
|
|
876
872
|
strategy._token_price_cache = {"test-token": 100.0}
|
|
877
|
-
strategy._token_price_timestamps = {"test-token": 0}
|
|
873
|
+
strategy._token_price_timestamps = {"test-token": 0}
|
|
878
874
|
|
|
879
875
|
# Should refresh because timestamp is stale
|
|
880
876
|
await strategy._get_token_price("test-token")
|
|
@@ -885,8 +881,6 @@ async def test_price_staleness_triggers_refresh(strategy, mock_adapter_responses
|
|
|
885
881
|
|
|
886
882
|
@pytest.mark.asyncio
|
|
887
883
|
async def test_partial_liquidate_prefers_wsteth_when_excess(strategy):
|
|
888
|
-
"""Partial liquidation should redeem wstETH first when collateral exceeds debt."""
|
|
889
|
-
|
|
890
884
|
# Token metadata
|
|
891
885
|
async def mock_get_token(token_id: str):
|
|
892
886
|
if token_id == USDC_TOKEN_ID:
|
|
@@ -906,10 +900,8 @@ async def test_partial_liquidate_prefers_wsteth_when_excess(strategy):
|
|
|
906
900
|
# Wallet balances (raw)
|
|
907
901
|
balances: dict[str, int] = {USDC_TOKEN_ID: 0, WSTETH_TOKEN_ID: 0}
|
|
908
902
|
|
|
909
|
-
async def
|
|
910
|
-
return
|
|
911
|
-
|
|
912
|
-
strategy.balance_adapter.get_balance = AsyncMock(side_effect=mock_get_balance)
|
|
903
|
+
async def mock_get_balance_raw(*, token_id: str, wallet_address: str, **_):
|
|
904
|
+
return balances.get(token_id, 0)
|
|
913
905
|
|
|
914
906
|
# Position snapshot: wstETH collateral > WETH debt
|
|
915
907
|
totals_usd = {
|
|
@@ -923,8 +915,6 @@ async def test_partial_liquidate_prefers_wsteth_when_excess(strategy):
|
|
|
923
915
|
snap.wsteth_price = 2000.0
|
|
924
916
|
snap.wsteth_dec = 18
|
|
925
917
|
|
|
926
|
-
strategy._accounting_snapshot = AsyncMock(return_value=(snap, (0.8, 0.8)))
|
|
927
|
-
|
|
928
918
|
# Collateral factors
|
|
929
919
|
strategy.moonwell_adapter.get_collateral_factor = AsyncMock(
|
|
930
920
|
return_value=(True, 0.8)
|
|
@@ -948,7 +938,7 @@ async def test_partial_liquidate_prefers_wsteth_when_excess(strategy):
|
|
|
948
938
|
async def mock_unlend(*, mtoken: str, amount: int):
|
|
949
939
|
if mtoken == M_WSTETH:
|
|
950
940
|
balances[WSTETH_TOKEN_ID] += int(amount)
|
|
951
|
-
return (True, "
|
|
941
|
+
return (True, {"block_number": 12345})
|
|
952
942
|
|
|
953
943
|
strategy.moonwell_adapter.unlend = AsyncMock(side_effect=mock_unlend)
|
|
954
944
|
|
|
@@ -967,14 +957,25 @@ async def test_partial_liquidate_prefers_wsteth_when_excess(strategy):
|
|
|
967
957
|
usd_out = (amt / 10**18) * 2000.0
|
|
968
958
|
usdc_out = int(usd_out * 10**6)
|
|
969
959
|
balances[USDC_TOKEN_ID] += usdc_out
|
|
970
|
-
return (True, {"to_amount": usdc_out})
|
|
960
|
+
return (True, {"to_amount": usdc_out, "block_number": 12346})
|
|
971
961
|
|
|
972
962
|
strategy.brap_adapter.swap_from_token_ids = AsyncMock(side_effect=mock_swap)
|
|
973
963
|
|
|
974
964
|
# Also need to mock lend since partial_liquidate may try to re-lend leftover wstETH
|
|
975
965
|
strategy.moonwell_adapter.lend = AsyncMock(return_value=(True, "success"))
|
|
976
966
|
|
|
977
|
-
|
|
967
|
+
with (
|
|
968
|
+
patch.object(
|
|
969
|
+
strategy, "_accounting_snapshot", new_callable=AsyncMock
|
|
970
|
+
) as mock_snap,
|
|
971
|
+
patch.object(
|
|
972
|
+
strategy, "_get_balance_raw", new_callable=AsyncMock
|
|
973
|
+
) as mock_balance,
|
|
974
|
+
):
|
|
975
|
+
mock_snap.return_value = (snap, (0.8, 0.8))
|
|
976
|
+
mock_balance.side_effect = mock_get_balance_raw
|
|
977
|
+
ok, msg = await strategy.partial_liquidate(usd_value=100.0)
|
|
978
|
+
|
|
978
979
|
assert ok
|
|
979
980
|
assert "available" in msg.lower()
|
|
980
981
|
|
|
@@ -986,7 +987,6 @@ async def test_partial_liquidate_prefers_wsteth_when_excess(strategy):
|
|
|
986
987
|
|
|
987
988
|
@pytest.mark.asyncio
|
|
988
989
|
async def test_partial_liquidate_uses_usdc_collateral_when_no_wsteth_excess(strategy):
|
|
989
|
-
"""If wstETH collateral doesn't exceed debt, partial liquidation should redeem USDC collateral."""
|
|
990
990
|
# Token metadata
|
|
991
991
|
strategy.token_adapter.get_token = AsyncMock(
|
|
992
992
|
side_effect=lambda token_id: (
|
|
@@ -1000,13 +1000,11 @@ async def test_partial_liquidate_uses_usdc_collateral_when_no_wsteth_excess(stra
|
|
|
1000
1000
|
|
|
1001
1001
|
balances: dict[str, int] = {USDC_TOKEN_ID: 0}
|
|
1002
1002
|
|
|
1003
|
-
async def
|
|
1004
|
-
return
|
|
1005
|
-
|
|
1006
|
-
strategy.balance_adapter.get_balance = AsyncMock(side_effect=mock_get_balance)
|
|
1003
|
+
async def mock_get_balance_raw(*, token_id: str, wallet_address: str, **_):
|
|
1004
|
+
return balances.get(token_id, 0)
|
|
1007
1005
|
|
|
1008
1006
|
totals_usd = {
|
|
1009
|
-
f"Base_{M_WSTETH}": 100.0,
|
|
1007
|
+
f"Base_{M_WSTETH}": 100.0,
|
|
1010
1008
|
f"Base_{M_USDC}": 500.0,
|
|
1011
1009
|
f"Base_{WETH}": -200.0,
|
|
1012
1010
|
}
|
|
@@ -1016,8 +1014,6 @@ async def test_partial_liquidate_uses_usdc_collateral_when_no_wsteth_excess(stra
|
|
|
1016
1014
|
snap.usdc_price = 1.0
|
|
1017
1015
|
snap.usdc_dec = 6
|
|
1018
1016
|
|
|
1019
|
-
strategy._accounting_snapshot = AsyncMock(return_value=(snap, (0.8, 0.8)))
|
|
1020
|
-
|
|
1021
1017
|
strategy.moonwell_adapter.get_collateral_factor = AsyncMock(
|
|
1022
1018
|
return_value=(True, 0.8)
|
|
1023
1019
|
)
|
|
@@ -1039,11 +1035,22 @@ async def test_partial_liquidate_uses_usdc_collateral_when_no_wsteth_excess(stra
|
|
|
1039
1035
|
async def mock_unlend(*, mtoken: str, amount: int):
|
|
1040
1036
|
if mtoken == M_USDC:
|
|
1041
1037
|
balances[USDC_TOKEN_ID] += int(amount)
|
|
1042
|
-
return (True, "
|
|
1038
|
+
return (True, {"block_number": 12345})
|
|
1043
1039
|
|
|
1044
1040
|
strategy.moonwell_adapter.unlend = AsyncMock(side_effect=mock_unlend)
|
|
1045
1041
|
|
|
1046
|
-
|
|
1042
|
+
with (
|
|
1043
|
+
patch.object(
|
|
1044
|
+
strategy, "_accounting_snapshot", new_callable=AsyncMock
|
|
1045
|
+
) as mock_snap,
|
|
1046
|
+
patch.object(
|
|
1047
|
+
strategy, "_get_balance_raw", new_callable=AsyncMock
|
|
1048
|
+
) as mock_balance,
|
|
1049
|
+
):
|
|
1050
|
+
mock_snap.return_value = (snap, (0.8, 0.8))
|
|
1051
|
+
mock_balance.side_effect = mock_get_balance_raw
|
|
1052
|
+
ok, msg = await strategy.partial_liquidate(usd_value=50.0)
|
|
1053
|
+
|
|
1047
1054
|
assert ok
|
|
1048
1055
|
assert "available" in msg.lower()
|
|
1049
1056
|
|