wayfinder-paths 0.1.19__py3-none-any.whl → 0.1.20__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 +0 -21
- 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 +0 -147
- 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 +0 -9
- 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 +9 -121
- 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/erc20_service.py +0 -1
- wayfinder_paths/core/utils/evm_helpers.py +0 -50
- 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.20.dist-info/METADATA +355 -0
- wayfinder_paths-0.1.20.dist-info/RECORD +129 -0
- 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.20.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.19.dist-info → wayfinder_paths-0.1.20.dist-info}/WHEEL +0 -0
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
"""Tests for BasisTradingStrategy."""
|
|
2
|
-
|
|
3
1
|
import json
|
|
4
2
|
from pathlib import Path
|
|
5
3
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
@@ -12,21 +10,17 @@ from wayfinder_paths.tests.test_utils import load_strategy_examples
|
|
|
12
10
|
|
|
13
11
|
|
|
14
12
|
def load_examples():
|
|
15
|
-
"""Load test examples from examples.json using shared utility."""
|
|
16
13
|
return load_strategy_examples(Path(__file__))
|
|
17
14
|
|
|
18
15
|
|
|
19
16
|
class TestBasisTradingStrategy:
|
|
20
|
-
"""Tests for BasisTradingStrategy."""
|
|
21
|
-
|
|
22
17
|
@pytest.fixture
|
|
23
18
|
def mock_hyperliquid_adapter(self):
|
|
24
|
-
"""Create mock HyperliquidAdapter."""
|
|
25
19
|
mock = MagicMock()
|
|
26
20
|
# Provide enough points to satisfy the strategy's lookback checks without making tests too slow.
|
|
27
21
|
n_points = 1200
|
|
28
22
|
start_ms = 1700000000000
|
|
29
|
-
step_ms = 3600 * 1000
|
|
23
|
+
step_ms = 3600 * 1000
|
|
30
24
|
mock.get_meta_and_asset_ctxs = AsyncMock(
|
|
31
25
|
return_value=(
|
|
32
26
|
True,
|
|
@@ -93,8 +87,8 @@ class TestBasisTradingStrategy:
|
|
|
93
87
|
True,
|
|
94
88
|
{
|
|
95
89
|
"levels": [
|
|
96
|
-
[{"px": "1999", "sz": "100", "n": 10}],
|
|
97
|
-
[{"px": "2001", "sz": "100", "n": 10}],
|
|
90
|
+
[{"px": "1999", "sz": "100", "n": 10}],
|
|
91
|
+
[{"px": "2001", "sz": "100", "n": 10}],
|
|
98
92
|
],
|
|
99
93
|
"midPx": "2000",
|
|
100
94
|
},
|
|
@@ -120,7 +114,7 @@ class TestBasisTradingStrategy:
|
|
|
120
114
|
True,
|
|
121
115
|
{
|
|
122
116
|
"marginSummary": {"accountValue": "0"},
|
|
123
|
-
"withdrawable": "100.0",
|
|
117
|
+
"withdrawable": "100.0",
|
|
124
118
|
"assetPositions": [],
|
|
125
119
|
},
|
|
126
120
|
)
|
|
@@ -139,9 +133,7 @@ class TestBasisTradingStrategy:
|
|
|
139
133
|
mock.get_open_orders = AsyncMock(return_value=(True, []))
|
|
140
134
|
mock.get_frontend_open_orders = AsyncMock(return_value=(True, []))
|
|
141
135
|
mock.get_valid_order_size = MagicMock(side_effect=lambda _asset, size: size)
|
|
142
|
-
mock.wait_for_deposit = AsyncMock(
|
|
143
|
-
return_value=(True, 100.0) # (deposit_confirmed, final_balance)
|
|
144
|
-
)
|
|
136
|
+
mock.wait_for_deposit = AsyncMock(return_value=(True, 100.0))
|
|
145
137
|
mock.wait_for_withdrawal = AsyncMock(
|
|
146
138
|
# tx_hash -> amount (float)
|
|
147
139
|
return_value=(True, {"0x123456": 100.0})
|
|
@@ -150,13 +142,11 @@ class TestBasisTradingStrategy:
|
|
|
150
142
|
|
|
151
143
|
@pytest.fixture
|
|
152
144
|
def ledger_adapter(self, tmp_path):
|
|
153
|
-
"""Create real LedgerAdapter with temp directory."""
|
|
154
145
|
ledger_client = LedgerClient(ledger_dir=tmp_path)
|
|
155
146
|
return LedgerAdapter(ledger_client=ledger_client)
|
|
156
147
|
|
|
157
148
|
@pytest.fixture
|
|
158
149
|
def strategy(self, mock_hyperliquid_adapter, ledger_adapter):
|
|
159
|
-
"""Create strategy with mocked market adapters but real ledger."""
|
|
160
150
|
with patch(
|
|
161
151
|
"wayfinder_paths.strategies.basis_trading_strategy.strategy.HyperliquidAdapter",
|
|
162
152
|
return_value=mock_hyperliquid_adapter,
|
|
@@ -222,7 +212,6 @@ class TestBasisTradingStrategy:
|
|
|
222
212
|
@pytest.mark.asyncio
|
|
223
213
|
@pytest.mark.smoke
|
|
224
214
|
async def test_smoke(self, strategy):
|
|
225
|
-
"""Smoke test: deposit → update → status → withdraw lifecycle."""
|
|
226
215
|
examples = load_examples()
|
|
227
216
|
smoke = examples["smoke"]
|
|
228
217
|
|
|
@@ -241,7 +230,6 @@ class TestBasisTradingStrategy:
|
|
|
241
230
|
success, msg = await strategy.deposit(**deposit_params)
|
|
242
231
|
assert success, f"Deposit failed: {msg}"
|
|
243
232
|
|
|
244
|
-
# Update
|
|
245
233
|
success, msg = await strategy.update()
|
|
246
234
|
assert success, f"Update failed: {msg}"
|
|
247
235
|
|
|
@@ -255,7 +243,6 @@ class TestBasisTradingStrategy:
|
|
|
255
243
|
|
|
256
244
|
@pytest.mark.asyncio
|
|
257
245
|
async def test_deposit_minimum(self, strategy):
|
|
258
|
-
"""Test minimum deposit validation."""
|
|
259
246
|
examples = load_examples()
|
|
260
247
|
min_fail = examples.get("min_deposit_fail", {})
|
|
261
248
|
|
|
@@ -269,7 +256,6 @@ class TestBasisTradingStrategy:
|
|
|
269
256
|
|
|
270
257
|
@pytest.mark.asyncio
|
|
271
258
|
async def test_update_without_deposit(self, strategy, mock_hyperliquid_adapter):
|
|
272
|
-
"""Test update fails without deposit."""
|
|
273
259
|
strategy.deposit_amount = 0.0
|
|
274
260
|
|
|
275
261
|
# No USDC in perp withdrawable or spot.
|
|
@@ -293,13 +279,11 @@ class TestBasisTradingStrategy:
|
|
|
293
279
|
|
|
294
280
|
@pytest.mark.asyncio
|
|
295
281
|
async def test_withdraw_without_deposit(self, strategy):
|
|
296
|
-
"""Test withdraw fails without deposit."""
|
|
297
282
|
success, msg = await strategy.withdraw()
|
|
298
283
|
assert success is False
|
|
299
284
|
|
|
300
285
|
@pytest.mark.asyncio
|
|
301
286
|
async def test_status(self, strategy):
|
|
302
|
-
"""Test status returns expected fields."""
|
|
303
287
|
status = await strategy.status()
|
|
304
288
|
assert "portfolio_value" in status
|
|
305
289
|
assert "net_deposit" in status
|
|
@@ -307,8 +291,6 @@ class TestBasisTradingStrategy:
|
|
|
307
291
|
|
|
308
292
|
@pytest.mark.asyncio
|
|
309
293
|
async def test_ledger_records_snapshot(self, strategy, tmp_path):
|
|
310
|
-
"""Test that status() records a snapshot to the ledger."""
|
|
311
|
-
# Get status (should record snapshot)
|
|
312
294
|
status = await strategy.status()
|
|
313
295
|
assert status is not None
|
|
314
296
|
|
|
@@ -325,30 +307,27 @@ class TestBasisTradingStrategy:
|
|
|
325
307
|
assert snapshot["portfolio_value"] == status["portfolio_value"]
|
|
326
308
|
|
|
327
309
|
def test_maintenance_rate(self):
|
|
328
|
-
"""Test maintenance rate calculation."""
|
|
329
310
|
from wayfinder_paths.strategies.basis_trading_strategy.strategy import (
|
|
330
311
|
BasisTradingStrategy,
|
|
331
312
|
)
|
|
332
313
|
|
|
333
314
|
rate = BasisTradingStrategy.maintenance_rate_from_max_leverage(50)
|
|
334
|
-
assert rate == 0.01
|
|
315
|
+
assert rate == 0.01
|
|
335
316
|
|
|
336
317
|
rate = BasisTradingStrategy.maintenance_rate_from_max_leverage(10)
|
|
337
|
-
assert rate == 0.05
|
|
318
|
+
assert rate == 0.05
|
|
338
319
|
|
|
339
320
|
def test_rolling_min_sum(self, strategy):
|
|
340
|
-
"""Test rolling minimum sum calculation."""
|
|
341
321
|
arr = [1, -2, 3, -4, 5]
|
|
342
322
|
result = strategy._rolling_min_sum(arr, 2)
|
|
343
|
-
assert result == -1
|
|
323
|
+
assert result == -1
|
|
344
324
|
|
|
345
325
|
def test_z_from_conf(self, strategy):
|
|
346
|
-
"""Test z-score calculation."""
|
|
347
326
|
z = strategy._z_from_conf(0.95)
|
|
348
|
-
assert 1.9 < z < 2.0
|
|
327
|
+
assert 1.9 < z < 2.0
|
|
349
328
|
|
|
350
329
|
z = strategy._z_from_conf(0.99)
|
|
351
|
-
assert 2.5 < z < 2.6
|
|
330
|
+
assert 2.5 < z < 2.6
|
|
352
331
|
|
|
353
332
|
@pytest.mark.asyncio
|
|
354
333
|
async def test_build_batch_snapshot_and_filter(self, strategy):
|
|
@@ -373,7 +352,6 @@ class TestBasisTradingStrategy:
|
|
|
373
352
|
async def test_get_undeployed_capital_empty(
|
|
374
353
|
self, strategy, mock_hyperliquid_adapter
|
|
375
354
|
):
|
|
376
|
-
"""Test _get_undeployed_capital with no capital."""
|
|
377
355
|
mock_hyperliquid_adapter.get_user_state = AsyncMock(
|
|
378
356
|
return_value=(
|
|
379
357
|
True,
|
|
@@ -395,7 +373,6 @@ class TestBasisTradingStrategy:
|
|
|
395
373
|
async def test_get_undeployed_capital_with_margin(
|
|
396
374
|
self, strategy, mock_hyperliquid_adapter
|
|
397
375
|
):
|
|
398
|
-
"""Test _get_undeployed_capital with withdrawable margin."""
|
|
399
376
|
mock_hyperliquid_adapter.get_user_state = AsyncMock(
|
|
400
377
|
return_value=(
|
|
401
378
|
True,
|
|
@@ -418,7 +395,6 @@ class TestBasisTradingStrategy:
|
|
|
418
395
|
|
|
419
396
|
@pytest.mark.asyncio
|
|
420
397
|
async def test_scale_up_position_no_position(self, strategy):
|
|
421
|
-
"""Test _scale_up_position fails without existing position."""
|
|
422
398
|
success, msg = await strategy._scale_up_position(100.0)
|
|
423
399
|
assert success is False
|
|
424
400
|
assert "No position to scale up" in msg
|
|
@@ -427,7 +403,6 @@ class TestBasisTradingStrategy:
|
|
|
427
403
|
async def test_scale_up_position_below_minimum(
|
|
428
404
|
self, strategy, mock_hyperliquid_adapter
|
|
429
405
|
):
|
|
430
|
-
"""Test _scale_up_position rejects below minimum notional."""
|
|
431
406
|
from wayfinder_paths.strategies.basis_trading_strategy.strategy import (
|
|
432
407
|
BasisPosition,
|
|
433
408
|
)
|
|
@@ -447,14 +422,13 @@ class TestBasisTradingStrategy:
|
|
|
447
422
|
# Try to scale with $5 (below $10 minimum notional)
|
|
448
423
|
# With 2x leverage, order_usd = 5 * (2/3) = 3.33, below $10
|
|
449
424
|
success, msg = await strategy._scale_up_position(5.0)
|
|
450
|
-
assert success
|
|
425
|
+
assert success
|
|
451
426
|
assert "below minimum notional" in msg
|
|
452
427
|
|
|
453
428
|
@pytest.mark.asyncio
|
|
454
429
|
async def test_update_with_idle_capital_scales_up(
|
|
455
430
|
self, strategy, mock_hyperliquid_adapter
|
|
456
431
|
):
|
|
457
|
-
"""Test update() calls _scale_up_position when idle capital exists."""
|
|
458
432
|
from wayfinder_paths.strategies.basis_trading_strategy.strategy import (
|
|
459
433
|
BasisPosition,
|
|
460
434
|
)
|
|
@@ -486,7 +460,7 @@ class TestBasisTradingStrategy:
|
|
|
486
460
|
"marginSummary": {
|
|
487
461
|
"accountValue": "120",
|
|
488
462
|
"withdrawable": "12",
|
|
489
|
-
"totalNtlPos": "112",
|
|
463
|
+
"totalNtlPos": "112",
|
|
490
464
|
},
|
|
491
465
|
"assetPositions": [
|
|
492
466
|
{
|
|
@@ -555,7 +529,6 @@ class TestBasisTradingStrategy:
|
|
|
555
529
|
async def test_ensure_builder_fee_approved_already_approved(
|
|
556
530
|
self, mock_hyperliquid_adapter, ledger_adapter
|
|
557
531
|
):
|
|
558
|
-
"""Test ensure_builder_fee_approved when already approved."""
|
|
559
532
|
with patch(
|
|
560
533
|
"wayfinder_paths.strategies.basis_trading_strategy.strategy.HyperliquidAdapter",
|
|
561
534
|
return_value=mock_hyperliquid_adapter,
|
|
@@ -588,7 +561,7 @@ class TestBasisTradingStrategy:
|
|
|
588
561
|
return_value=(
|
|
589
562
|
True,
|
|
590
563
|
30,
|
|
591
|
-
)
|
|
564
|
+
)
|
|
592
565
|
)
|
|
593
566
|
mock_hyperliquid_adapter.approve_builder_fee = AsyncMock(
|
|
594
567
|
return_value=(True, {"status": "ok"})
|
|
@@ -604,7 +577,6 @@ class TestBasisTradingStrategy:
|
|
|
604
577
|
async def test_ensure_builder_fee_approved_needs_approval(
|
|
605
578
|
self, mock_hyperliquid_adapter, ledger_adapter
|
|
606
579
|
):
|
|
607
|
-
"""Test ensure_builder_fee_approved when approval is needed."""
|
|
608
580
|
with patch(
|
|
609
581
|
"wayfinder_paths.strategies.basis_trading_strategy.strategy.HyperliquidAdapter",
|
|
610
582
|
return_value=mock_hyperliquid_adapter,
|
|
@@ -634,7 +606,7 @@ class TestBasisTradingStrategy:
|
|
|
634
606
|
|
|
635
607
|
# Mock get_max_builder_fee returning insufficient approval
|
|
636
608
|
mock_hyperliquid_adapter.get_max_builder_fee = AsyncMock(
|
|
637
|
-
return_value=(True, 0)
|
|
609
|
+
return_value=(True, 0)
|
|
638
610
|
)
|
|
639
611
|
mock_hyperliquid_adapter.approve_builder_fee = AsyncMock(
|
|
640
612
|
return_value=(True, {"status": "ok"})
|
|
@@ -650,7 +622,6 @@ class TestBasisTradingStrategy:
|
|
|
650
622
|
async def test_portfolio_value_includes_spot_holdings(
|
|
651
623
|
self, strategy, mock_hyperliquid_adapter
|
|
652
624
|
):
|
|
653
|
-
"""Portfolio value should include non-USDC spot holdings."""
|
|
654
625
|
# Perp account has $100
|
|
655
626
|
mock_hyperliquid_adapter.get_user_state = AsyncMock(
|
|
656
627
|
return_value=(
|
|
@@ -678,11 +649,10 @@ class TestBasisTradingStrategy:
|
|
|
678
649
|
total, hl_value, vault_value = await strategy._get_total_portfolio_value()
|
|
679
650
|
# 100 (perp) + 50 (USDC) + 0.5*2000 (ETH) = 1150
|
|
680
651
|
assert hl_value == 1150.0
|
|
681
|
-
assert total == 1150.0
|
|
652
|
+
assert total == 1150.0
|
|
682
653
|
|
|
683
654
|
@pytest.mark.asyncio
|
|
684
655
|
async def test_portfolio_value_usdc_only(self, strategy, mock_hyperliquid_adapter):
|
|
685
|
-
"""Portfolio value with only USDC spot balance."""
|
|
686
656
|
mock_hyperliquid_adapter.get_user_state = AsyncMock(
|
|
687
657
|
return_value=(
|
|
688
658
|
True,
|
|
@@ -704,7 +674,6 @@ class TestBasisTradingStrategy:
|
|
|
704
674
|
|
|
705
675
|
@pytest.mark.asyncio
|
|
706
676
|
async def test_withdraw_detects_spot_usdc(self, strategy, mock_hyperliquid_adapter):
|
|
707
|
-
"""Withdraw should detect funds in spot USDC (not perp margin)."""
|
|
708
677
|
# Perp is empty
|
|
709
678
|
mock_hyperliquid_adapter.get_user_state = AsyncMock(
|
|
710
679
|
return_value=(
|
|
@@ -732,7 +701,6 @@ class TestBasisTradingStrategy:
|
|
|
732
701
|
async def test_update_detects_hl_balance_when_deposit_zero(
|
|
733
702
|
self, strategy, mock_hyperliquid_adapter
|
|
734
703
|
):
|
|
735
|
-
"""Update should detect Hyperliquid balance when deposit_amount is 0."""
|
|
736
704
|
strategy.deposit_amount = 0
|
|
737
705
|
|
|
738
706
|
# Hyperliquid has $50 in perp account
|
|
@@ -756,11 +724,6 @@ class TestBasisTradingStrategy:
|
|
|
756
724
|
async def test_update_spot_usdc_only_rebalances_before_open(
|
|
757
725
|
self, strategy, mock_hyperliquid_adapter
|
|
758
726
|
):
|
|
759
|
-
"""
|
|
760
|
-
If funds are mostly in spot USDC (e.g., after liquidation), update() should:
|
|
761
|
-
- detect deposit from spot+perp USDC
|
|
762
|
-
- transfer spot->perp to reach the target split before opening.
|
|
763
|
-
"""
|
|
764
727
|
strategy.deposit_amount = 0.0
|
|
765
728
|
strategy.current_position = None
|
|
766
729
|
|
|
@@ -829,7 +792,6 @@ class TestBasisTradingStrategy:
|
|
|
829
792
|
async def test_update_near_liquidation_closes_and_redeploys(
|
|
830
793
|
self, strategy, mock_hyperliquid_adapter
|
|
831
794
|
):
|
|
832
|
-
"""Near-liquidation should trigger an emergency close+redeploy (bypasses cooldown)."""
|
|
833
795
|
from wayfinder_paths.strategies.basis_trading_strategy.strategy import (
|
|
834
796
|
BasisPosition,
|
|
835
797
|
)
|
|
@@ -895,18 +857,11 @@ class TestBasisTradingStrategy:
|
|
|
895
857
|
|
|
896
858
|
@pytest.mark.asyncio
|
|
897
859
|
async def test_net_deposit_handles_float_return(self, strategy):
|
|
898
|
-
"""Test that strategy correctly handles float from get_strategy_net_deposit.
|
|
899
|
-
|
|
900
|
-
The ledger adapter returns (success, float) not (success, dict).
|
|
901
|
-
This test ensures the strategy doesn't try to call .get() on the float,
|
|
902
|
-
which would raise "'float' object has no attribute 'get'".
|
|
903
|
-
"""
|
|
904
860
|
# Mock ledger adapter to return a float (not a dict)
|
|
905
861
|
strategy.ledger_adapter.get_strategy_net_deposit = AsyncMock(
|
|
906
862
|
return_value=(True, 1500.0)
|
|
907
863
|
)
|
|
908
864
|
|
|
909
|
-
# Call status() which internally uses get_strategy_net_deposit
|
|
910
865
|
status = await strategy.status()
|
|
911
866
|
|
|
912
867
|
# Verify net_deposit is correctly set from the float
|
|
@@ -916,10 +871,6 @@ class TestBasisTradingStrategy:
|
|
|
916
871
|
async def test_setup_handles_float_net_deposit(
|
|
917
872
|
self, mock_hyperliquid_adapter, ledger_adapter
|
|
918
873
|
):
|
|
919
|
-
"""Test that setup() correctly handles float from get_strategy_net_deposit.
|
|
920
|
-
|
|
921
|
-
This catches if code is changed to expect a dict with .get('net_deposit').
|
|
922
|
-
"""
|
|
923
874
|
with patch(
|
|
924
875
|
"wayfinder_paths.strategies.basis_trading_strategy.strategy.HyperliquidAdapter",
|
|
925
876
|
return_value=mock_hyperliquid_adapter,
|
|
@@ -6,8 +6,6 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
@dataclass
|
|
8
8
|
class BasisCandidate:
|
|
9
|
-
"""Represents a potential basis trading opportunity."""
|
|
10
|
-
|
|
11
9
|
coin: str
|
|
12
10
|
spot_pair: str
|
|
13
11
|
spot_asset_id: int
|
|
@@ -26,8 +24,6 @@ class BasisCandidate:
|
|
|
26
24
|
|
|
27
25
|
@dataclass
|
|
28
26
|
class BasisPosition:
|
|
29
|
-
"""Tracks an active basis position."""
|
|
30
|
-
|
|
31
27
|
coin: str
|
|
32
28
|
spot_asset_id: int
|
|
33
29
|
perp_asset_id: int
|
|
@@ -1,84 +1,93 @@
|
|
|
1
|
-
#
|
|
1
|
+
# HyperLend Stable Yield Strategy
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
- Examples: `examples.json`
|
|
5
|
-
- Tests: `test_strategy.py`
|
|
3
|
+
Stablecoin yield optimization on HyperLend (HyperEVM).
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
- **Module**: `wayfinder_paths.strategies.hyperlend_stable_yield_strategy.strategy.HyperlendStableYieldStrategy`
|
|
6
|
+
- **Chain**: HyperEVM
|
|
7
|
+
- **Token**: USDT0
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Overview
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
This strategy allocates USDT0 across HyperLend stablecoin markets by:
|
|
12
|
+
1. Transferring USDT0 (plus HYPE gas buffer) from main wallet to strategy wallet
|
|
13
|
+
2. Sampling HyperLend hourly rate history
|
|
14
|
+
3. Running bootstrap tournament analysis to identify best-performing stablecoin
|
|
15
|
+
4. Swapping and supplying to HyperLend
|
|
16
|
+
5. Enforcing hysteresis rotation policy to prevent excessive churn
|
|
15
17
|
|
|
16
|
-
## Key
|
|
18
|
+
## Key Parameters
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
| Parameter | Value | Description |
|
|
21
|
+
|-----------|-------|-------------|
|
|
22
|
+
| `MIN_USDT0_DEPOSIT_AMOUNT` | 1 | Minimum deposit amount |
|
|
23
|
+
| `GAS_MAXIMUM` | 0.1 HYPE | Maximum gas per deposit |
|
|
24
|
+
| `HORIZON_HOURS` | 6 | Analysis horizon |
|
|
25
|
+
| `TRIALS` | 4000 | Bootstrap simulation trials |
|
|
26
|
+
| `HYSTERESIS_DWELL_HOURS` | 168 | Rotation cooldown |
|
|
27
|
+
| `HYSTERESIS_Z` | 1.15 | APY improvement threshold |
|
|
28
|
+
| `ROTATION_COOLDOWN` | 168 hours | Minimum time between rotations |
|
|
29
|
+
| `APY_REBALANCE_THRESHOLD` | 0.0035 | 35 bps edge required to rotate |
|
|
25
30
|
|
|
26
|
-
## Adapters
|
|
31
|
+
## Adapters Used
|
|
27
32
|
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
+
- **BalanceAdapter**: Token/pool balances, wallet transfers
|
|
34
|
+
- **TokenAdapter**: Token metadata (USDT0, HYPE)
|
|
35
|
+
- **LedgerAdapter**: Net deposit, rotation history
|
|
36
|
+
- **BRAPAdapter**: Swap quotes and execution
|
|
37
|
+
- **HyperlendAdapter**: Asset views, lend/withdraw operations
|
|
33
38
|
|
|
34
39
|
## Actions
|
|
35
40
|
|
|
36
41
|
### Deposit
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
```bash
|
|
44
|
+
poetry run python wayfinder_paths/run_strategy.py hyperlend_stable_yield_strategy \
|
|
45
|
+
--action deposit --main-token-amount 25 --gas-token-amount 0.02 --config config.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- Validates USDT0 and HYPE balances in main wallet
|
|
49
|
+
- Transfers HYPE for gas buffer
|
|
50
|
+
- Moves USDT0 to strategy wallet
|
|
51
|
+
- Clears cached asset snapshots
|
|
42
52
|
|
|
43
53
|
### Update
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
```bash
|
|
56
|
+
poetry run python wayfinder_paths/run_strategy.py hyperlend_stable_yield_strategy \
|
|
57
|
+
--action update --config config.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- Refreshes HyperLend asset snapshots
|
|
61
|
+
- Runs tournament analysis to find winner
|
|
62
|
+
- Enforces cooldown (unless short-circuit triggered)
|
|
63
|
+
- Executes rotation via BRAP if new asset wins
|
|
64
|
+
- Sweeps residual balances and lends via HyperlendAdapter
|
|
49
65
|
|
|
50
66
|
### Status
|
|
51
67
|
|
|
52
|
-
|
|
68
|
+
```bash
|
|
69
|
+
poetry run python wayfinder_paths/run_strategy.py hyperlend_stable_yield_strategy \
|
|
70
|
+
--action status --config config.json
|
|
71
|
+
```
|
|
53
72
|
|
|
54
|
-
|
|
55
|
-
- `
|
|
56
|
-
- `
|
|
73
|
+
Returns:
|
|
74
|
+
- `portfolio_value`: Active lend balance
|
|
75
|
+
- `net_deposit`: From LedgerAdapter
|
|
76
|
+
- `strategy_status`: Current asset, APY, balances, tournament projections
|
|
57
77
|
|
|
58
78
|
### Withdraw
|
|
59
79
|
|
|
60
|
-
- Unwinds existing HyperLend positions, swaps back to USDT0 when necessary, returns USDT0 and residual HYPE to the main wallet via `BalanceAdapter`, and clears cached state.
|
|
61
|
-
|
|
62
|
-
## Running locally
|
|
63
|
-
|
|
64
80
|
```bash
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# Generate main wallet (writes config.json)
|
|
69
|
-
# Creates a main wallet (or use 'just create-strategy' which auto-creates wallets)
|
|
70
|
-
poetry run python wayfinder_paths/scripts/make_wallets.py -n 1
|
|
81
|
+
poetry run python wayfinder_paths/run_strategy.py hyperlend_stable_yield_strategy \
|
|
82
|
+
--action withdraw --config config.json
|
|
83
|
+
```
|
|
71
84
|
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
- Unwinds HyperLend positions
|
|
86
|
+
- Swaps back to USDT0 if needed
|
|
87
|
+
- Returns USDT0 and residual HYPE to main wallet
|
|
74
88
|
|
|
75
|
-
|
|
76
|
-
poetry run python wayfinder_paths/run_strategy.py hyperlend_stable_yield_strategy --action status --config $(pwd)/config.json
|
|
89
|
+
## Testing
|
|
77
90
|
|
|
78
|
-
|
|
79
|
-
poetry run
|
|
80
|
-
poetry run python wayfinder_paths/run_strategy.py hyperlend_stable_yield_strategy --action update --config $(pwd)/config.json
|
|
81
|
-
poetry run python wayfinder_paths/run_strategy.py hyperlend_stable_yield_strategy --action withdraw --config $(pwd)/config.json
|
|
91
|
+
```bash
|
|
92
|
+
poetry run pytest wayfinder_paths/strategies/hyperlend_stable_yield_strategy/ -v
|
|
82
93
|
```
|
|
83
|
-
|
|
84
|
-
Wallet addresses/labels are auto-resolved from `config.json`. Set `NETWORK=testnet` in your config to run the orchestration without touching live HyperEVM endpoints.
|
|
@@ -74,12 +74,12 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
74
74
|
ASSETS_SNAPSHOT_TTL_SECONDS = 20.0
|
|
75
75
|
DEFAULT_LOOKBACK_HOURS = 24 * 7
|
|
76
76
|
APY_REBALANCE_THRESHOLD = 0.0035
|
|
77
|
-
TOURNAMENT_MODE = "joint"
|
|
77
|
+
TOURNAMENT_MODE = "joint"
|
|
78
78
|
ROTATION_COOLDOWN = timedelta(hours=168)
|
|
79
79
|
P_BEST_ROTATION_THRESHOLD = 0.4
|
|
80
80
|
MAX_CANDIDATES = 5
|
|
81
81
|
MIN_STABLE_SWAP_TOKENS = 1e-3
|
|
82
|
-
MAX_GAS = 0.1
|
|
82
|
+
MAX_GAS = 0.1
|
|
83
83
|
|
|
84
84
|
INFO = StratDescriptor(
|
|
85
85
|
description=f"""Multi-strategy allocator that converts USDT0 into the most consistently rewarding HyperLend stablecoin and continuously checks if a rotation is justified.
|
|
@@ -104,7 +104,7 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
104
104
|
gas_token_id="hyperliquid-hyperevm",
|
|
105
105
|
deposit_token_id="usdt0-hyperevm",
|
|
106
106
|
minimum_net_deposit=10,
|
|
107
|
-
gas_maximum=MAX_GAS,
|
|
107
|
+
gas_maximum=MAX_GAS,
|
|
108
108
|
gas_threshold=MAX_GAS / 3,
|
|
109
109
|
# risk indicators
|
|
110
110
|
volatility=Volatility.LOW,
|
|
@@ -218,7 +218,6 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
218
218
|
main_wallet_cfg = self.config.get("main_wallet")
|
|
219
219
|
strategy_wallet_cfg = self.config.get("strategy_wallet")
|
|
220
220
|
|
|
221
|
-
# Validate wallets are configured
|
|
222
221
|
if not strategy_wallet_cfg or not strategy_wallet_cfg.get("address"):
|
|
223
222
|
raise ValueError(
|
|
224
223
|
"strategy_wallet not configured. Provide strategy_wallet address in config or ensure wallet is properly configured for your wallet provider"
|
|
@@ -428,8 +427,6 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
428
427
|
return total_tokens
|
|
429
428
|
|
|
430
429
|
def _amount_to_wei(self, token: dict[str, Any], amount: Decimal) -> int:
|
|
431
|
-
"""Convert ``amount`` tokens into base units using existing helpers."""
|
|
432
|
-
|
|
433
430
|
if amount <= 0:
|
|
434
431
|
return 0
|
|
435
432
|
|
|
@@ -746,21 +743,6 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
746
743
|
def _get_token_address(
|
|
747
744
|
self, token: dict[str, Any] | None, chain_code: str = "hyperevm"
|
|
748
745
|
) -> str | None:
|
|
749
|
-
"""
|
|
750
|
-
Extract token address from various token data structures.
|
|
751
|
-
|
|
752
|
-
Handles:
|
|
753
|
-
1. Top-level 'address' field (e.g., hype_token_info)
|
|
754
|
-
2. 'addresses' dict with chain_code key (e.g., addresses: {'hyperevm': '0x...'})
|
|
755
|
-
3. 'chain_addresses' dict with chain_code key (e.g., chain_addresses: {'hyperevm': {'address': '0x...'}})
|
|
756
|
-
|
|
757
|
-
Args:
|
|
758
|
-
token: Token dictionary with address information
|
|
759
|
-
chain_code: Chain code to look up in nested structures (default: 'hyperevm')
|
|
760
|
-
|
|
761
|
-
Returns:
|
|
762
|
-
Token address string or None if not found
|
|
763
|
-
"""
|
|
764
746
|
if not token:
|
|
765
747
|
return None
|
|
766
748
|
|
|
@@ -910,7 +892,6 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
910
892
|
return (True, ". ".join(messages))
|
|
911
893
|
|
|
912
894
|
async def exit(self, **kwargs) -> StatusTuple:
|
|
913
|
-
"""Transfer funds from strategy wallet to main wallet."""
|
|
914
895
|
self.logger.info("EXIT: Transferring remaining funds to main wallet")
|
|
915
896
|
|
|
916
897
|
strategy_address = self._get_strategy_wallet_address()
|
|
@@ -953,7 +934,7 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
953
934
|
)
|
|
954
935
|
if hype_ok and hype_raw:
|
|
955
936
|
hype_balance = float(hype_raw.get("balance", 0))
|
|
956
|
-
tx_fee_reserve = 0.1
|
|
937
|
+
tx_fee_reserve = 0.1
|
|
957
938
|
transferable_hype = hype_balance - tx_fee_reserve
|
|
958
939
|
if transferable_hype > 0.01:
|
|
959
940
|
self.logger.info(
|
|
@@ -1050,8 +1031,6 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
1050
1031
|
return actions
|
|
1051
1032
|
|
|
1052
1033
|
async def update(self) -> StatusTuple:
|
|
1053
|
-
"""Rebalance or update positions."""
|
|
1054
|
-
|
|
1055
1034
|
await self._hydrate_position_from_chain()
|
|
1056
1035
|
|
|
1057
1036
|
redeploy_tokens = await self._estimate_redeploy_tokens()
|
|
@@ -2194,7 +2173,6 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
2194
2173
|
|
|
2195
2174
|
raw_balance_wei = asset.get("underlying_wallet_balance_wei")
|
|
2196
2175
|
try:
|
|
2197
|
-
# Handle both "decimals" (plural) and "decimal" (singular) from API
|
|
2198
2176
|
token_decimals = token.get("decimals") or token.get("decimal")
|
|
2199
2177
|
asset_decimals = asset.get("decimals") or asset.get("decimal")
|
|
2200
2178
|
if token_decimals is not None:
|
|
@@ -2392,7 +2370,6 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
2392
2370
|
|
|
2393
2371
|
@staticmethod
|
|
2394
2372
|
async def policies() -> list[str]:
|
|
2395
|
-
"""Return policy strings used to scope on-chain permissions."""
|
|
2396
2373
|
return [
|
|
2397
2374
|
any_hyperliquid_l1_payload(),
|
|
2398
2375
|
any_hyperliquid_user_payload(),
|
|
@@ -15,7 +15,6 @@ elif sys.path.index(_wayfinder_path_str) > 0:
|
|
|
15
15
|
|
|
16
16
|
import pytest # noqa: E402
|
|
17
17
|
|
|
18
|
-
# Import test utilities
|
|
19
18
|
try:
|
|
20
19
|
from tests.test_utils import get_canonical_examples, load_strategy_examples
|
|
21
20
|
except ImportError:
|
|
@@ -36,7 +35,6 @@ from wayfinder_paths.strategies.hyperlend_stable_yield_strategy.strategy import
|
|
|
36
35
|
|
|
37
36
|
@pytest.fixture
|
|
38
37
|
def strategy():
|
|
39
|
-
"""Create a strategy instance for testing with minimal config."""
|
|
40
38
|
mock_config = {
|
|
41
39
|
"main_wallet": {"address": "0x1234567890123456789012345678901234567890"},
|
|
42
40
|
"strategy_wallet": {"address": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"},
|
|
@@ -339,7 +337,6 @@ def strategy():
|
|
|
339
337
|
@pytest.mark.asyncio
|
|
340
338
|
@pytest.mark.smoke
|
|
341
339
|
async def test_smoke(strategy):
|
|
342
|
-
"""REQUIRED: Basic smoke test - verifies strategy lifecycle."""
|
|
343
340
|
examples = load_strategy_examples(Path(__file__))
|
|
344
341
|
smoke_data = examples["smoke"]
|
|
345
342
|
|
|
@@ -355,7 +352,6 @@ async def test_smoke(strategy):
|
|
|
355
352
|
assert isinstance(msg, str)
|
|
356
353
|
|
|
357
354
|
result = await strategy.update(**smoke_data.get("update", {}))
|
|
358
|
-
# update() returns (ok, msg, should_notify) or (ok, msg)
|
|
359
355
|
ok = result[0]
|
|
360
356
|
assert isinstance(ok, bool)
|
|
361
357
|
|
|
@@ -365,11 +361,6 @@ async def test_smoke(strategy):
|
|
|
365
361
|
|
|
366
362
|
@pytest.mark.asyncio
|
|
367
363
|
async def test_canonical_usage(strategy):
|
|
368
|
-
"""REQUIRED: Test canonical usage examples from examples.json (minimum).
|
|
369
|
-
|
|
370
|
-
Canonical usage = all positive usage examples (excluding error cases).
|
|
371
|
-
This is the MINIMUM requirement - feel free to add more test cases here.
|
|
372
|
-
"""
|
|
373
364
|
examples = load_strategy_examples(Path(__file__))
|
|
374
365
|
canonical = get_canonical_examples(examples)
|
|
375
366
|
|
|
@@ -394,7 +385,6 @@ async def test_canonical_usage(strategy):
|
|
|
394
385
|
|
|
395
386
|
@pytest.mark.asyncio
|
|
396
387
|
async def test_error_cases(strategy):
|
|
397
|
-
"""OPTIONAL: Test error scenarios from examples.json."""
|
|
398
388
|
examples = load_strategy_examples(Path(__file__))
|
|
399
389
|
|
|
400
390
|
for example_name, example_data in examples.items():
|