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.

Files changed (98) hide show
  1. wayfinder_paths/__init__.py +0 -2
  2. wayfinder_paths/adapters/balance_adapter/README.md +59 -45
  3. wayfinder_paths/adapters/balance_adapter/adapter.py +1 -22
  4. wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -14
  5. wayfinder_paths/adapters/brap_adapter/README.md +61 -184
  6. wayfinder_paths/adapters/brap_adapter/__init__.py +0 -4
  7. wayfinder_paths/adapters/brap_adapter/adapter.py +1 -148
  8. wayfinder_paths/adapters/brap_adapter/test_adapter.py +0 -15
  9. wayfinder_paths/adapters/hyperlend_adapter/__init__.py +0 -4
  10. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +1 -10
  11. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +0 -17
  12. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +3 -312
  13. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +1 -71
  14. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +0 -57
  15. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +0 -17
  16. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +2 -42
  17. wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +1 -9
  18. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +15 -47
  19. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +0 -7
  20. wayfinder_paths/adapters/ledger_adapter/README.md +54 -74
  21. wayfinder_paths/adapters/ledger_adapter/__init__.py +0 -4
  22. wayfinder_paths/adapters/ledger_adapter/adapter.py +0 -106
  23. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +0 -12
  24. wayfinder_paths/adapters/moonwell_adapter/README.md +67 -106
  25. wayfinder_paths/adapters/moonwell_adapter/__init__.py +0 -4
  26. wayfinder_paths/adapters/moonwell_adapter/adapter.py +10 -122
  27. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +84 -83
  28. wayfinder_paths/adapters/pool_adapter/README.md +30 -51
  29. wayfinder_paths/adapters/pool_adapter/__init__.py +0 -4
  30. wayfinder_paths/adapters/pool_adapter/adapter.py +0 -19
  31. wayfinder_paths/adapters/pool_adapter/test_adapter.py +0 -8
  32. wayfinder_paths/adapters/token_adapter/README.md +41 -49
  33. wayfinder_paths/adapters/token_adapter/adapter.py +0 -32
  34. wayfinder_paths/adapters/token_adapter/test_adapter.py +1 -12
  35. wayfinder_paths/conftest.py +0 -8
  36. wayfinder_paths/core/__init__.py +0 -2
  37. wayfinder_paths/core/adapters/BaseAdapter.py +0 -22
  38. wayfinder_paths/core/adapters/__init__.py +0 -5
  39. wayfinder_paths/core/adapters/models.py +0 -5
  40. wayfinder_paths/core/analytics/__init__.py +0 -2
  41. wayfinder_paths/core/analytics/bootstrap.py +0 -16
  42. wayfinder_paths/core/analytics/stats.py +0 -7
  43. wayfinder_paths/core/analytics/test_analytics.py +5 -34
  44. wayfinder_paths/core/clients/BRAPClient.py +0 -35
  45. wayfinder_paths/core/clients/ClientManager.py +0 -51
  46. wayfinder_paths/core/clients/HyperlendClient.py +0 -77
  47. wayfinder_paths/core/clients/LedgerClient.py +2 -122
  48. wayfinder_paths/core/clients/PoolClient.py +0 -2
  49. wayfinder_paths/core/clients/TokenClient.py +0 -39
  50. wayfinder_paths/core/clients/WalletClient.py +0 -15
  51. wayfinder_paths/core/clients/WayfinderClient.py +0 -24
  52. wayfinder_paths/core/clients/__init__.py +0 -4
  53. wayfinder_paths/core/clients/protocols.py +25 -98
  54. wayfinder_paths/core/config.py +0 -24
  55. wayfinder_paths/core/constants/__init__.py +0 -7
  56. wayfinder_paths/core/constants/base.py +2 -9
  57. wayfinder_paths/core/constants/erc20_abi.py +0 -5
  58. wayfinder_paths/core/constants/hyperlend_abi.py +0 -7
  59. wayfinder_paths/core/constants/moonwell_abi.py +0 -35
  60. wayfinder_paths/core/engine/StrategyJob.py +0 -32
  61. wayfinder_paths/core/strategies/Strategy.py +0 -99
  62. wayfinder_paths/core/strategies/__init__.py +0 -2
  63. wayfinder_paths/core/utils/__init__.py +0 -1
  64. wayfinder_paths/core/utils/evm_helpers.py +0 -50
  65. wayfinder_paths/core/utils/{erc20_service.py → tokens.py} +25 -21
  66. wayfinder_paths/core/utils/transaction.py +0 -1
  67. wayfinder_paths/run_strategy.py +0 -46
  68. wayfinder_paths/scripts/create_strategy.py +0 -17
  69. wayfinder_paths/scripts/make_wallets.py +1 -4
  70. wayfinder_paths/strategies/basis_trading_strategy/README.md +71 -163
  71. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +0 -24
  72. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +36 -400
  73. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +15 -64
  74. wayfinder_paths/strategies/basis_trading_strategy/types.py +0 -4
  75. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +65 -56
  76. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +4 -27
  77. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +0 -10
  78. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +71 -72
  79. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +23 -227
  80. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +120 -113
  81. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +64 -59
  82. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +4 -44
  83. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +2 -35
  84. wayfinder_paths/templates/adapter/README.md +107 -46
  85. wayfinder_paths/templates/adapter/adapter.py +0 -9
  86. wayfinder_paths/templates/adapter/test_adapter.py +0 -19
  87. wayfinder_paths/templates/strategy/README.md +113 -59
  88. wayfinder_paths/templates/strategy/strategy.py +0 -22
  89. wayfinder_paths/templates/strategy/test_strategy.py +0 -28
  90. wayfinder_paths/tests/test_test_coverage.py +2 -12
  91. wayfinder_paths/tests/test_utils.py +1 -31
  92. wayfinder_paths-0.1.21.dist-info/METADATA +355 -0
  93. wayfinder_paths-0.1.21.dist-info/RECORD +129 -0
  94. {wayfinder_paths-0.1.19.dist-info → wayfinder_paths-0.1.21.dist-info}/WHEEL +1 -1
  95. wayfinder_paths/core/adapters/base.py +0 -5
  96. wayfinder_paths-0.1.19.dist-info/METADATA +0 -592
  97. wayfinder_paths-0.1.19.dist-info/RECORD +0 -130
  98. {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 # 0.1 ETH
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
- result = await strategy._swap_with_retries(
341
- from_token_id="usd-coin-base",
342
- to_token_id="l2-standard-bridged-weth-base-base",
343
- amount=1000000,
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 patch("asyncio.sleep", new_callable=AsyncMock): # Speed up test
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 patch("asyncio.sleep", new_callable=AsyncMock):
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 pytest.raises(SwapOutcomeUnknownError):
414
- await strategy._swap_with_retries(
415
- from_token_id=USDC_TOKEN_ID,
416
- to_token_id=WETH_TOKEN_ID,
417
- amount=1000000,
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) # small debt
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 get_balance_side_effect(*, token_id: str, wallet_address: str, **_):
619
- return (True, balances.get(token_id, 0))
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
- lent = await strategy._atomic_deposit_iteration(borrow_amt_wei)
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
- async def get_balance_side_effect(*, token_id: str, wallet_address: str):
687
- return (True, balances.get(token_id, 0))
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
- strategy.balance_adapter.get_balance = AsyncMock(
690
- side_effect=get_balance_side_effect
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
- success, msg = await strategy._reconcile_wallet_into_position(
704
- collateral_factors=(0.8, 0.8),
705
- max_batch_usd=100000.0,
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 balance_side_effect(token_id, wallet_address):
734
+ async def get_balance_raw_side_effect(*, token_id: str, wallet_address: str, **_):
734
735
  if "weth" in token_id.lower():
735
- return (True, 100 * 10**18) # 100 WETH
736
- return (True, 0)
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
- success, msg = await strategy._sweep_token_balances(
746
- target_token_id="usd-coin-base",
747
- exclude=set(),
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 # 50 bps
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), # cf_u=0.8, cf_w=1.3 (higher than MIN_HF)
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 # Error code
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
- """Test that stale prices trigger a refresh."""
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} # Very old timestamp
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 mock_get_balance(*, token_id: str, wallet_address: str):
910
- return (True, balances.get(token_id, 0))
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, "success")
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
- ok, msg = await strategy.partial_liquidate(usd_value=100.0)
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 mock_get_balance(*, token_id: str, wallet_address: str):
1004
- return (True, balances.get(token_id, 0))
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, # <= debt
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, "success")
1038
+ return (True, {"block_number": 12345})
1043
1039
 
1044
1040
  strategy.moonwell_adapter.unlend = AsyncMock(side_effect=mock_unlend)
1045
1041
 
1046
- ok, msg = await strategy.partial_liquidate(usd_value=50.0)
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