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.
- wayfinder_paths/adapters/balance_adapter/adapter.py +40 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +3 -3
- wayfinder_paths/adapters/brap_adapter/adapter.py +66 -15
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +6 -6
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +14 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +7 -7
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +6 -6
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +12 -12
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +6 -6
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +332 -9
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +13 -13
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +2 -2
- wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -4
- wayfinder_paths/core/constants/erc20_abi.py +0 -11
- wayfinder_paths/core/engine/StrategyJob.py +3 -1
- wayfinder_paths/core/services/base.py +1 -0
- wayfinder_paths/core/services/local_evm_txn.py +19 -3
- wayfinder_paths/core/services/local_token_txn.py +1 -5
- wayfinder_paths/core/services/test_local_evm_txn.py +145 -0
- wayfinder_paths/core/strategies/Strategy.py +16 -2
- wayfinder_paths/core/utils/evm_helpers.py +0 -7
- wayfinder_paths/policies/erc20.py +1 -1
- wayfinder_paths/run_strategy.py +5 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +67 -0
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +6 -6
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +71 -2
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +6 -5
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2249 -1282
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +282 -121
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +65 -0
- wayfinder_paths/templates/adapter/README.md +1 -1
- {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/METADATA +1 -1
- {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/RECORD +35 -35
- wayfinder_paths/abis/generic/erc20.json +0 -383
- {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/LICENSE +0 -0
- {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, "
|
|
222
|
-
) as
|
|
223
|
-
|
|
224
|
-
with patch.object(
|
|
225
|
-
|
|
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, "
|
|
228
|
-
) as
|
|
229
|
-
|
|
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, "
|
|
232
|
-
) as
|
|
233
|
-
|
|
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
|
-
|
|
243
|
+
status = await strategy._status()
|
|
240
244
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
421
|
-
|
|
422
|
-
)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
|
472
|
+
async def test_post_run_guard_restores_delta_via_reconcile(
|
|
448
473
|
strategy, mock_adapter_responses
|
|
449
474
|
):
|
|
450
|
-
"""
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
529
|
+
async def test_post_run_guard_delevers_to_delta_when_reconcile_insufficient(
|
|
482
530
|
strategy, mock_adapter_responses
|
|
483
531
|
):
|
|
484
|
-
"""
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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(*,
|
|
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
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
(True, {"underlying_balance": 0})
|
|
581
|
-
|
|
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(*,
|
|
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.
|
|
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
|
|
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(
|
|
637
|
-
|
|
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(*,
|
|
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
|
-
|
|
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(*,
|
|
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
|
-
|
|
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(),
|