wayfinder-paths 0.1.14__py3-none-any.whl → 0.1.15__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 (36) hide show
  1. wayfinder_paths/adapters/balance_adapter/adapter.py +40 -0
  2. wayfinder_paths/adapters/balance_adapter/test_adapter.py +3 -3
  3. wayfinder_paths/adapters/brap_adapter/adapter.py +66 -15
  4. wayfinder_paths/adapters/brap_adapter/test_adapter.py +6 -6
  5. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +14 -0
  6. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +7 -7
  7. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +6 -6
  8. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +12 -12
  9. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +6 -6
  10. wayfinder_paths/adapters/moonwell_adapter/adapter.py +332 -9
  11. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +13 -13
  12. wayfinder_paths/adapters/pool_adapter/test_adapter.py +2 -2
  13. wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -4
  14. wayfinder_paths/core/constants/erc20_abi.py +0 -11
  15. wayfinder_paths/core/engine/StrategyJob.py +3 -1
  16. wayfinder_paths/core/services/base.py +1 -0
  17. wayfinder_paths/core/services/local_evm_txn.py +19 -3
  18. wayfinder_paths/core/services/local_token_txn.py +1 -5
  19. wayfinder_paths/core/services/test_local_evm_txn.py +145 -0
  20. wayfinder_paths/core/strategies/Strategy.py +16 -2
  21. wayfinder_paths/core/utils/evm_helpers.py +0 -7
  22. wayfinder_paths/policies/erc20.py +1 -1
  23. wayfinder_paths/run_strategy.py +5 -0
  24. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +67 -0
  25. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +6 -6
  26. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +71 -2
  27. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +6 -5
  28. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2249 -1282
  29. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +282 -121
  30. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +65 -0
  31. wayfinder_paths/templates/adapter/README.md +1 -1
  32. {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/METADATA +1 -1
  33. {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/RECORD +35 -35
  34. wayfinder_paths/abis/generic/erc20.json +0 -383
  35. {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/LICENSE +0 -0
  36. {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/WHEEL +0 -0
@@ -217,32 +217,36 @@ async def test_canonical_usage(strategy, mock_adapter_responses):
217
217
  @pytest.mark.asyncio
218
218
  async def test_status_returns_status_dict(strategy, mock_adapter_responses):
219
219
  """Test that _status returns a proper StatusDict."""
220
+ snap = MagicMock()
221
+ snap.totals_usd = {}
222
+ snap.ltv = 0.5
223
+ snap.debt_usd = 100.0
224
+ snap.net_equity_usd = 0.0
225
+
220
226
  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
227
+ strategy, "_accounting_snapshot", new_callable=AsyncMock
228
+ ) as mock_snap:
229
+ mock_snap.return_value = (snap, (0.8, 0.8))
230
+ with patch.object(
231
+ strategy, "_get_gas_balance", new_callable=AsyncMock
232
+ ) as mock_gas:
233
+ mock_gas.return_value = 100000000000000000 # 0.1 ETH
226
234
  with patch.object(
227
- strategy, "_get_gas_balance", new_callable=AsyncMock
228
- ) as mock_gas:
229
- mock_gas.return_value = 100000000000000000 # 0.1 ETH
235
+ strategy, "get_peg_diff", new_callable=AsyncMock
236
+ ) as mock_peg:
237
+ mock_peg.return_value = 0.001
230
238
  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": {}}
239
+ strategy, "quote", new_callable=AsyncMock
240
+ ) as mock_quote:
241
+ mock_quote.return_value = {"apy": 0.1, "data": {}}
238
242
 
239
- status = await strategy._status()
243
+ status = await strategy._status()
240
244
 
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
245
+ assert "portfolio_value" in status
246
+ assert "net_deposit" in status
247
+ assert "strategy_status" in status
248
+ assert "gas_available" in status
249
+ assert "gassed_up" in status
246
250
 
247
251
 
248
252
  @pytest.mark.asyncio
@@ -417,93 +421,181 @@ async def test_swap_with_retries_aborts_on_unknown_outcome(
417
421
 
418
422
 
419
423
  @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()
424
+ 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
+ snap = MagicMock()
427
+ snap.wallet_wsteth = 0
428
+ snap.usdc_supplied = 0
429
+ snap.wsteth_supplied = 0
430
+ snap.weth_debt = 5 * 10**18
431
+ snap.debt_usd = 100.0
432
+ snap.hf = 1.3
433
+ snap.capacity_usd = 1000.0
434
+ snap.totals_usd = {
435
+ f"Base_{M_WSTETH}": 96.0,
436
+ f"Base_{M_USDC}": 0.0,
437
+ f"Base_{WETH}": -100.0,
438
+ }
441
439
 
442
- assert success is True
443
- assert "balanced" in msg.lower()
440
+ with patch.object(strategy, "_get_gas_balance", new_callable=AsyncMock) as mock_gas:
441
+ mock_gas.return_value = int(0.01 * 10**18)
442
+ with patch.object(
443
+ strategy, "_get_collateral_factors", new_callable=AsyncMock
444
+ ) as mock_cf:
445
+ mock_cf.return_value = (0.8, 0.8)
446
+ with patch.object(
447
+ strategy, "_accounting_snapshot", new_callable=AsyncMock
448
+ ) as mock_snap:
449
+ mock_snap.return_value = (snap, (0.8, 0.8))
450
+ with patch.object(
451
+ strategy, "_ensure_markets_for_state", new_callable=AsyncMock
452
+ ) as mock_markets:
453
+ mock_markets.return_value = (True, "ok")
454
+ with patch.object(
455
+ strategy,
456
+ "_reconcile_wallet_into_position",
457
+ new_callable=AsyncMock,
458
+ ) as mock_reconcile:
459
+ with patch.object(
460
+ strategy,
461
+ "_settle_weth_debt_to_target_usd",
462
+ new_callable=AsyncMock,
463
+ ) as mock_settle:
464
+ ok, msg = await strategy._post_run_guard(mode="operate")
465
+ assert ok is True
466
+ assert "delta ok" in msg.lower()
467
+ assert not mock_reconcile.called
468
+ assert not mock_settle.called
444
469
 
445
470
 
446
471
  @pytest.mark.asyncio
447
- async def test_balance_weth_debt_rebalances_when_excess_debt(
472
+ async def test_post_run_guard_restores_delta_via_reconcile(
448
473
  strategy, mock_adapter_responses
449
474
  ):
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()
475
+ """Post-run guard should attempt wallet reconcile in operate mode when net short."""
476
+ snap1 = MagicMock()
477
+ snap1.wallet_wsteth = 0
478
+ snap1.usdc_supplied = 0
479
+ snap1.wsteth_supplied = 0
480
+ snap1.weth_debt = 5 * 10**18
481
+ snap1.debt_usd = 100.0
482
+ snap1.hf = 1.3
483
+ snap1.capacity_usd = 1000.0
484
+ snap1.totals_usd = {f"Base_{M_WSTETH}": 0.0, f"Base_{WETH}": -100.0}
485
+
486
+ snap2 = MagicMock()
487
+ snap2.wallet_wsteth = 0
488
+ snap2.usdc_supplied = 0
489
+ snap2.wsteth_supplied = 0
490
+ snap2.weth_debt = 5 * 10**18
491
+ snap2.debt_usd = 100.0
492
+ snap2.hf = 1.3
493
+ snap2.capacity_usd = 1000.0
494
+ snap2.totals_usd = {f"Base_{M_WSTETH}": 98.0, f"Base_{WETH}": -100.0}
495
+
496
+ with patch.object(strategy, "_get_gas_balance", new_callable=AsyncMock) as mock_gas:
497
+ mock_gas.return_value = int(0.01 * 10**18)
498
+ with patch.object(
499
+ strategy, "_get_collateral_factors", new_callable=AsyncMock
500
+ ) as mock_cf:
501
+ mock_cf.return_value = (0.8, 0.8)
502
+ with patch.object(
503
+ strategy, "_accounting_snapshot", new_callable=AsyncMock
504
+ ) as mock_snap:
505
+ mock_snap.side_effect = [(snap1, (0.8, 0.8)), (snap2, (0.8, 0.8))]
506
+ with patch.object(
507
+ strategy, "_ensure_markets_for_state", new_callable=AsyncMock
508
+ ) as mock_markets:
509
+ mock_markets.return_value = (True, "ok")
510
+ with patch.object(
511
+ strategy,
512
+ "_reconcile_wallet_into_position",
513
+ new_callable=AsyncMock,
514
+ ) as mock_reconcile:
515
+ mock_reconcile.return_value = (True, "ok")
516
+ with patch.object(
517
+ strategy,
518
+ "_settle_weth_debt_to_target_usd",
519
+ new_callable=AsyncMock,
520
+ ) as mock_settle:
521
+ ok, msg = await strategy._post_run_guard(mode="operate")
522
+ assert ok is True
523
+ assert "restored by reconcile" in msg.lower()
524
+ assert mock_reconcile.called
525
+ assert not mock_settle.called
478
526
 
479
527
 
480
528
  @pytest.mark.asyncio
481
- async def test_balance_weth_debt_rebalances_when_no_wsteth_position(
529
+ async def test_post_run_guard_delevers_to_delta_when_reconcile_insufficient(
482
530
  strategy, mock_adapter_responses
483
531
  ):
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()
532
+ """Post-run guard should delever debt when still net short after reconcile."""
533
+ snap1 = MagicMock()
534
+ snap1.wallet_wsteth = 0
535
+ snap1.usdc_supplied = 0
536
+ snap1.wsteth_supplied = 0
537
+ snap1.weth_debt = 5 * 10**18
538
+ snap1.debt_usd = 100.0
539
+ snap1.hf = 1.3
540
+ snap1.capacity_usd = 1000.0
541
+ snap1.totals_usd = {f"Base_{M_WSTETH}": 0.0, f"Base_{WETH}": -100.0}
542
+
543
+ snap2 = MagicMock()
544
+ snap2.wallet_wsteth = 0
545
+ snap2.usdc_supplied = 0
546
+ snap2.wsteth_supplied = 0
547
+ snap2.weth_debt = 5 * 10**18
548
+ snap2.debt_usd = 100.0
549
+ snap2.hf = 1.3
550
+ snap2.capacity_usd = 1000.0
551
+ snap2.totals_usd = {f"Base_{M_WSTETH}": 0.0, f"Base_{WETH}": -100.0}
552
+
553
+ snap3 = MagicMock()
554
+ snap3.wallet_wsteth = 0
555
+ snap3.usdc_supplied = 0
556
+ snap3.wsteth_supplied = 0
557
+ snap3.weth_debt = int(0.005 * 10**18) # small debt
558
+ snap3.debt_usd = 12.0
559
+ snap3.hf = 1.3
560
+ snap3.capacity_usd = 1000.0
561
+ # mismatch = debt_usd - wsteth_coll_usd = 12 - 15 = -3 (within DELTA_TOL_USD of 5)
562
+ snap3.totals_usd = {f"Base_{M_WSTETH}": 15.0, f"Base_{WETH}": -12.0}
563
+
564
+ with patch.object(strategy, "_get_gas_balance", new_callable=AsyncMock) as mock_gas:
565
+ mock_gas.return_value = int(0.01 * 10**18)
566
+ with patch.object(
567
+ strategy, "_get_collateral_factors", new_callable=AsyncMock
568
+ ) as mock_cf:
569
+ mock_cf.return_value = (0.8, 0.8)
570
+ with patch.object(
571
+ strategy, "_accounting_snapshot", new_callable=AsyncMock
572
+ ) as mock_snap:
573
+ mock_snap.side_effect = [
574
+ (snap1, (0.8, 0.8)),
575
+ (snap2, (0.8, 0.8)),
576
+ (snap3, (0.8, 0.8)),
577
+ ]
578
+ with patch.object(
579
+ strategy, "_ensure_markets_for_state", new_callable=AsyncMock
580
+ ) as mock_markets:
581
+ mock_markets.return_value = (True, "ok")
582
+ with patch.object(
583
+ strategy,
584
+ "_reconcile_wallet_into_position",
585
+ new_callable=AsyncMock,
586
+ ) as mock_reconcile:
587
+ mock_reconcile.return_value = (True, "ok")
588
+ with patch.object(
589
+ strategy,
590
+ "_settle_weth_debt_to_target_usd",
591
+ new_callable=AsyncMock,
592
+ ) as mock_settle:
593
+ mock_settle.return_value = (True, "ok")
594
+ ok, msg = await strategy._post_run_guard(mode="operate")
595
+ assert ok is True
596
+ assert "delta ok" in msg.lower()
597
+ assert mock_reconcile.called
598
+ assert mock_settle.called
507
599
 
508
600
 
509
601
  @pytest.mark.asyncio
@@ -523,8 +615,7 @@ async def test_atomic_deposit_iteration_swaps_from_eth_when_borrow_surfaces_as_e
523
615
  WSTETH_TOKEN_ID: 0,
524
616
  }
525
617
 
526
- async def get_balance_side_effect(*, query: str, wallet_address: str, **_):
527
- token_id = query if isinstance(query, str) else (query or {}).get("token_id")
618
+ async def get_balance_side_effect(*, token_id: str, wallet_address: str, **_):
528
619
  return (True, balances.get(token_id, 0))
529
620
 
530
621
  strategy.balance_adapter.get_balance = AsyncMock(
@@ -570,17 +661,21 @@ async def test_atomic_deposit_iteration_swaps_from_eth_when_borrow_surfaces_as_e
570
661
 
571
662
 
572
663
  @pytest.mark.asyncio
573
- async def test_complete_unpaired_weth_borrow_uses_eth_inventory(
664
+ async def test_reconcile_wallet_into_position_uses_eth_inventory(
574
665
  strategy, mock_adapter_responses
575
666
  ):
576
667
  """If debt exists but wstETH collateral is missing, use wallet ETH to complete the loop."""
577
- # wstETH pos is empty; WETH debt exists
578
- strategy.moonwell_adapter.get_pos = AsyncMock(
579
- side_effect=[
580
- (True, {"underlying_balance": 0}), # mwstETH
581
- (True, {"borrow_balance": 5 * 10**18}), # mWETH debt
582
- ]
583
- )
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)
584
679
 
585
680
  balances: dict[str, int] = {
586
681
  ETH_TOKEN_ID: 10 * 10**18,
@@ -588,8 +683,7 @@ async def test_complete_unpaired_weth_borrow_uses_eth_inventory(
588
683
  WSTETH_TOKEN_ID: 0,
589
684
  }
590
685
 
591
- async def get_balance_side_effect(*, query: str, wallet_address: str):
592
- token_id = query if isinstance(query, str) else (query or {}).get("token_id")
686
+ async def get_balance_side_effect(*, token_id: str, wallet_address: str):
593
687
  return (True, balances.get(token_id, 0))
594
688
 
595
689
  strategy.balance_adapter.get_balance = AsyncMock(
@@ -606,10 +700,13 @@ async def test_complete_unpaired_weth_borrow_uses_eth_inventory(
606
700
 
607
701
  strategy._swap_with_retries = AsyncMock(side_effect=swap_side_effect)
608
702
 
609
- success, msg = await strategy._complete_unpaired_weth_borrow()
703
+ success, msg = await strategy._reconcile_wallet_into_position(
704
+ collateral_factors=(0.8, 0.8),
705
+ max_batch_usd=100000.0,
706
+ )
610
707
 
611
708
  assert success is True
612
- assert "completed unpaired borrow" in msg.lower()
709
+ assert strategy._swap_with_retries.called
613
710
  strategy.moonwell_adapter.lend.assert_called()
614
711
 
615
712
 
@@ -633,10 +730,8 @@ async def test_sweep_token_balances_sweeps_tokens(strategy, mock_adapter_respons
633
730
  strategy.min_withdraw_usd = 1.0
634
731
 
635
732
  # Mock balance returns (has some WETH dust)
636
- def balance_side_effect(*, query, wallet_address, **_):
637
- token_id = query if isinstance(query, str) else (query or {}).get("token_id")
638
- token_id_str = (token_id or "").lower()
639
- if "weth" in token_id_str:
733
+ def balance_side_effect(token_id, wallet_address):
734
+ if "weth" in token_id.lower():
640
735
  return (True, 100 * 10**18) # 100 WETH
641
736
  return (True, 0)
642
737
 
@@ -695,6 +790,62 @@ def test_min_leverage_gain_constant_exists(strategy):
695
790
  assert strategy._MIN_LEVERAGE_GAIN_BPS == 50e-4 # 50 bps
696
791
 
697
792
 
793
+ @pytest.mark.asyncio
794
+ async def test_update_runs_post_run_guard(strategy, mock_adapter_responses):
795
+ with patch.object(strategy, "_update_impl", new_callable=AsyncMock) as mock_impl:
796
+ with patch.object(
797
+ strategy, "_post_run_guard", new_callable=AsyncMock
798
+ ) as mock_guard:
799
+ mock_impl.return_value = (True, "ok")
800
+ mock_guard.return_value = (True, "guard ok")
801
+
802
+ ok, msg = await strategy.update()
803
+
804
+ assert ok is True
805
+ assert "finalizer:" in msg
806
+ mock_guard.assert_awaited()
807
+ assert mock_guard.call_args.kwargs.get("mode") == "operate"
808
+
809
+
810
+ @pytest.mark.asyncio
811
+ async def test_update_fails_if_post_run_guard_fails(strategy, mock_adapter_responses):
812
+ with patch.object(strategy, "_update_impl", new_callable=AsyncMock) as mock_impl:
813
+ with patch.object(
814
+ strategy, "_post_run_guard", new_callable=AsyncMock
815
+ ) as mock_guard:
816
+ mock_impl.return_value = (True, "ok")
817
+ mock_guard.return_value = (False, "guard failed")
818
+
819
+ ok, msg = await strategy.update()
820
+
821
+ assert ok is False
822
+ assert "finalizer failed" in msg.lower()
823
+
824
+
825
+ @pytest.mark.asyncio
826
+ async def test_withdraw_runs_post_run_guard_only_on_failure(
827
+ strategy, mock_adapter_responses
828
+ ):
829
+ with patch.object(strategy, "_withdraw_impl", new_callable=AsyncMock) as mock_impl:
830
+ with patch.object(
831
+ strategy, "_post_run_guard", new_callable=AsyncMock
832
+ ) as mock_guard:
833
+ mock_impl.return_value = (True, "ok")
834
+ mock_guard.return_value = (True, "guard ok")
835
+
836
+ ok, _ = await strategy.withdraw()
837
+
838
+ assert ok is True
839
+ mock_guard.assert_not_awaited()
840
+
841
+ mock_impl.return_value = (False, "failed")
842
+ ok, msg = await strategy.withdraw()
843
+ assert ok is False
844
+ assert "finalizer:" in msg
845
+ mock_guard.assert_awaited()
846
+ assert mock_guard.call_args.kwargs.get("mode") == "exit"
847
+
848
+
698
849
  @pytest.mark.asyncio
699
850
  async def test_leverage_calc_handles_high_cf_w(strategy, mock_adapter_responses):
700
851
  """Test that leverage calculation handles high collateral factors safely."""
@@ -755,8 +906,7 @@ async def test_partial_liquidate_prefers_wsteth_when_excess(strategy):
755
906
  # Wallet balances (raw)
756
907
  balances: dict[str, int] = {USDC_TOKEN_ID: 0, WSTETH_TOKEN_ID: 0}
757
908
 
758
- async def mock_get_balance(*, query: str, wallet_address: str):
759
- token_id = query if isinstance(query, str) else (query or {}).get("token_id")
909
+ async def mock_get_balance(*, token_id: str, wallet_address: str):
760
910
  return (True, balances.get(token_id, 0))
761
911
 
762
912
  strategy.balance_adapter.get_balance = AsyncMock(side_effect=mock_get_balance)
@@ -767,7 +917,13 @@ async def test_partial_liquidate_prefers_wsteth_when_excess(strategy):
767
917
  f"Base_{M_USDC}": 1000.0,
768
918
  f"Base_{WETH}": -200.0,
769
919
  }
770
- strategy._aggregate_positions = AsyncMock(return_value=({}, totals_usd))
920
+ snap = MagicMock()
921
+ snap.totals_usd = totals_usd
922
+ snap.debt_usd = 200.0
923
+ snap.wsteth_price = 2000.0
924
+ snap.wsteth_dec = 18
925
+
926
+ strategy._accounting_snapshot = AsyncMock(return_value=(snap, (0.8, 0.8)))
771
927
 
772
928
  # Collateral factors
773
929
  strategy.moonwell_adapter.get_collateral_factor = AsyncMock(
@@ -844,8 +1000,7 @@ async def test_partial_liquidate_uses_usdc_collateral_when_no_wsteth_excess(stra
844
1000
 
845
1001
  balances: dict[str, int] = {USDC_TOKEN_ID: 0}
846
1002
 
847
- async def mock_get_balance(*, query: str, wallet_address: str):
848
- token_id = query if isinstance(query, str) else (query or {}).get("token_id")
1003
+ async def mock_get_balance(*, token_id: str, wallet_address: str):
849
1004
  return (True, balances.get(token_id, 0))
850
1005
 
851
1006
  strategy.balance_adapter.get_balance = AsyncMock(side_effect=mock_get_balance)
@@ -855,7 +1010,13 @@ async def test_partial_liquidate_uses_usdc_collateral_when_no_wsteth_excess(stra
855
1010
  f"Base_{M_USDC}": 500.0,
856
1011
  f"Base_{WETH}": -200.0,
857
1012
  }
858
- strategy._aggregate_positions = AsyncMock(return_value=({}, totals_usd))
1013
+ snap = MagicMock()
1014
+ snap.totals_usd = totals_usd
1015
+ snap.debt_usd = 200.0
1016
+ snap.usdc_price = 1.0
1017
+ snap.usdc_dec = 6
1018
+
1019
+ strategy._accounting_snapshot = AsyncMock(return_value=(snap, (0.8, 0.8)))
859
1020
 
860
1021
  strategy.moonwell_adapter.get_collateral_factor = AsyncMock(
861
1022
  return_value=(True, 0.8)
@@ -1089,6 +1089,71 @@ class StablecoinYieldStrategy(Strategy):
1089
1089
  f"Successfully withdrew {amount} USDC from strategy: {breakdown_msg}",
1090
1090
  )
1091
1091
 
1092
+ async def exit(self, **kwargs) -> StatusTuple:
1093
+ """Transfer funds from strategy wallet to main wallet."""
1094
+ logger.info("EXIT: Transferring remaining funds to main wallet")
1095
+
1096
+ strategy_address = self._get_strategy_wallet_address()
1097
+ main_address = self._get_main_wallet_address()
1098
+
1099
+ if strategy_address.lower() == main_address.lower():
1100
+ return (True, "Main wallet is strategy wallet, no transfer needed")
1101
+
1102
+ transferred_items = []
1103
+
1104
+ # Transfer USDC to main wallet
1105
+ usdc_ok, usdc_raw = await self.balance_adapter.get_balance(
1106
+ token_id="usd-coin-base",
1107
+ wallet_address=strategy_address,
1108
+ )
1109
+ if usdc_ok and usdc_raw:
1110
+ usdc_balance = float(usdc_raw.get("balance", 0))
1111
+ if usdc_balance > 1.0:
1112
+ logger.info(f"Transferring {usdc_balance:.2f} USDC to main wallet")
1113
+ (
1114
+ success,
1115
+ msg,
1116
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
1117
+ query="usd-coin-base",
1118
+ amount=usdc_balance,
1119
+ strategy_name=self.name,
1120
+ skip_ledger=False,
1121
+ )
1122
+ if success:
1123
+ transferred_items.append(f"{usdc_balance:.2f} USDC")
1124
+ else:
1125
+ logger.warning(f"USDC transfer failed: {msg}")
1126
+
1127
+ # Transfer ETH (minus reserve for tx fees) to main wallet
1128
+ eth_ok, eth_raw = await self.balance_adapter.get_balance(
1129
+ token_id="ethereum-base",
1130
+ wallet_address=strategy_address,
1131
+ )
1132
+ if eth_ok and eth_raw:
1133
+ eth_balance = float(eth_raw.get("balance", 0))
1134
+ tx_fee_reserve = 0.0002
1135
+ transferable_eth = eth_balance - tx_fee_reserve
1136
+ if transferable_eth > 0.0001:
1137
+ logger.info(f"Transferring {transferable_eth:.6f} ETH to main wallet")
1138
+ (
1139
+ success,
1140
+ msg,
1141
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
1142
+ query="ethereum-base",
1143
+ amount=transferable_eth,
1144
+ strategy_name=self.name,
1145
+ skip_ledger=False,
1146
+ )
1147
+ if success:
1148
+ transferred_items.append(f"{transferable_eth:.6f} ETH")
1149
+ else:
1150
+ logger.warning(f"ETH transfer failed: {msg}")
1151
+
1152
+ if not transferred_items:
1153
+ return (True, "No funds to transfer to main wallet")
1154
+
1155
+ return (True, f"Transferred to main wallet: {', '.join(transferred_items)}")
1156
+
1092
1157
  async def _get_last_rotation_time(self, wallet_address: str) -> datetime | None:
1093
1158
  success, data = await self.ledger_adapter.get_strategy_latest_transactions(
1094
1159
  wallet_address=self._get_strategy_wallet_address(),
@@ -77,7 +77,7 @@ async def test_get_pools():
77
77
  ):
78
78
  adapter = MyAdapter(config={})
79
79
  success, data = await adapter.get_pools(["pool-1"])
80
- assert success is True
80
+ assert success
81
81
  assert "pools" in data
82
82
  ```
83
83
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wayfinder-paths
3
- Version: 0.1.14
3
+ Version: 0.1.15
4
4
  Summary: Wayfinder Path: strategies and adapters
5
5
  Author: Wayfinder
6
6
  Author-email: dev@wayfinder.ai