wayfinder-paths 0.1.6__py3-none-any.whl → 0.1.8__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 (50) hide show
  1. wayfinder_paths/adapters/balance_adapter/README.md +0 -10
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +0 -20
  3. wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -30
  4. wayfinder_paths/adapters/brap_adapter/adapter.py +3 -2
  5. wayfinder_paths/adapters/brap_adapter/test_adapter.py +9 -13
  6. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +14 -7
  7. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
  8. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
  9. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
  10. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
  11. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
  12. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
  13. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
  14. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
  15. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
  16. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +7 -6
  17. wayfinder_paths/adapters/pool_adapter/README.md +3 -28
  18. wayfinder_paths/adapters/pool_adapter/adapter.py +0 -72
  19. wayfinder_paths/adapters/pool_adapter/examples.json +0 -43
  20. wayfinder_paths/adapters/pool_adapter/test_adapter.py +4 -54
  21. wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -14
  22. wayfinder_paths/core/adapters/models.py +9 -4
  23. wayfinder_paths/core/analytics/__init__.py +11 -0
  24. wayfinder_paths/core/analytics/bootstrap.py +57 -0
  25. wayfinder_paths/core/analytics/stats.py +48 -0
  26. wayfinder_paths/core/analytics/test_analytics.py +170 -0
  27. wayfinder_paths/core/clients/BRAPClient.py +1 -0
  28. wayfinder_paths/core/clients/LedgerClient.py +2 -7
  29. wayfinder_paths/core/clients/PoolClient.py +0 -16
  30. wayfinder_paths/core/clients/WalletClient.py +0 -27
  31. wayfinder_paths/core/clients/protocols.py +104 -18
  32. wayfinder_paths/scripts/make_wallets.py +9 -0
  33. wayfinder_paths/scripts/run_strategy.py +124 -0
  34. wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
  35. wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
  36. wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
  37. wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
  38. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
  39. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
  40. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
  41. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
  42. wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
  43. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +1 -9
  44. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +36 -5
  45. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +367 -278
  46. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +204 -7
  47. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/METADATA +32 -3
  48. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/RECORD +50 -27
  49. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/LICENSE +0 -0
  50. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/WHEEL +0 -0
@@ -0,0 +1,727 @@
1
+ """Tests for BasisTradingStrategy."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from unittest.mock import AsyncMock, MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from wayfinder_paths.adapters.ledger_adapter.adapter import LedgerAdapter
10
+ from wayfinder_paths.core.clients.LedgerClient import LedgerClient
11
+ from wayfinder_paths.tests.test_utils import load_strategy_examples
12
+
13
+
14
+ def load_examples():
15
+ """Load test examples from examples.json using shared utility."""
16
+ return load_strategy_examples(Path(__file__))
17
+
18
+
19
+ class TestBasisTradingStrategy:
20
+ """Tests for BasisTradingStrategy."""
21
+
22
+ @pytest.fixture
23
+ def mock_hyperliquid_adapter(self):
24
+ """Create mock HyperliquidAdapter."""
25
+ mock = MagicMock()
26
+ # Provide enough points to satisfy the strategy's lookback checks without making tests too slow.
27
+ n_points = 1200
28
+ start_ms = 1700000000000
29
+ step_ms = 3600 * 1000 # 1h
30
+ mock.get_meta_and_asset_ctxs = AsyncMock(
31
+ return_value=(
32
+ True,
33
+ [
34
+ {
35
+ "universe": [
36
+ {"name": "ETH", "maxLeverage": 50, "marginTableId": 1},
37
+ {"name": "BTC", "maxLeverage": 50, "marginTableId": 2},
38
+ ]
39
+ },
40
+ [
41
+ {
42
+ "openInterest": "1000",
43
+ "markPx": "2000",
44
+ "dayNtlVlm": "10000000",
45
+ },
46
+ {
47
+ "openInterest": "500",
48
+ "markPx": "50000",
49
+ "dayNtlVlm": "50000000",
50
+ },
51
+ ],
52
+ ],
53
+ )
54
+ )
55
+ mock.get_spot_meta = AsyncMock(
56
+ return_value=(
57
+ True,
58
+ {
59
+ "tokens": [
60
+ {"index": 0, "name": "ETH"},
61
+ {"index": 1, "name": "USDC"},
62
+ ],
63
+ "universe": [{"tokens": [0, 1], "index": 0}],
64
+ },
65
+ )
66
+ )
67
+ mock.get_funding_history = AsyncMock(
68
+ return_value=(
69
+ True,
70
+ [
71
+ {"fundingRate": "0.0001", "time": start_ms + i * step_ms}
72
+ for i in range(n_points)
73
+ ],
74
+ )
75
+ )
76
+ mock.get_candles = AsyncMock(
77
+ return_value=(
78
+ True,
79
+ [
80
+ {
81
+ "t": start_ms + i * step_ms,
82
+ "o": "2000",
83
+ "h": "2050",
84
+ "l": "1980",
85
+ "c": "2020",
86
+ }
87
+ for i in range(n_points)
88
+ ],
89
+ )
90
+ )
91
+ mock.get_spot_l2_book = AsyncMock(
92
+ return_value=(
93
+ True,
94
+ {
95
+ "levels": [
96
+ [{"px": "1999", "sz": "100", "n": 10}], # bids
97
+ [{"px": "2001", "sz": "100", "n": 10}], # asks
98
+ ],
99
+ "midPx": "2000",
100
+ },
101
+ )
102
+ )
103
+ mock.get_margin_table = AsyncMock(
104
+ return_value=(
105
+ True,
106
+ {
107
+ "marginTiers": [
108
+ {"lowerBound": 0, "maxLeverage": 50},
109
+ ]
110
+ },
111
+ )
112
+ )
113
+ mock.coin_to_asset = {"ETH": 1, "BTC": 0}
114
+ mock.asset_to_sz_decimals = {0: 4, 1: 3, 10000: 6}
115
+ mock.get_all_mid_prices = AsyncMock(
116
+ return_value=(True, {"ETH": 2000.0, "BTC": 50000.0})
117
+ )
118
+ mock.get_user_state = AsyncMock(
119
+ return_value=(
120
+ True,
121
+ {
122
+ "marginSummary": {"accountValue": "0", "withdrawable": "0"},
123
+ "assetPositions": [],
124
+ },
125
+ )
126
+ )
127
+ mock.get_spot_user_state = AsyncMock(return_value=(True, {"balances": []}))
128
+ return mock
129
+
130
+ @pytest.fixture
131
+ def ledger_adapter(self, tmp_path):
132
+ """Create real LedgerAdapter with temp directory."""
133
+ ledger_client = LedgerClient(ledger_dir=tmp_path)
134
+ return LedgerAdapter(ledger_client=ledger_client)
135
+
136
+ @pytest.fixture
137
+ def strategy(self, mock_hyperliquid_adapter, ledger_adapter):
138
+ """Create strategy with mocked market adapters but real ledger."""
139
+ with patch(
140
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.HyperliquidAdapter",
141
+ return_value=mock_hyperliquid_adapter,
142
+ ):
143
+ with patch(
144
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.BalanceAdapter"
145
+ ):
146
+ with patch(
147
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.TokenAdapter"
148
+ ):
149
+ with patch(
150
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.LedgerAdapter",
151
+ return_value=ledger_adapter,
152
+ ):
153
+ with patch(
154
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.WalletManager"
155
+ ):
156
+ from wayfinder_paths.strategies.basis_trading_strategy.strategy import (
157
+ BasisTradingStrategy,
158
+ )
159
+
160
+ s = BasisTradingStrategy(
161
+ config={
162
+ "main_wallet": {"address": "0x1234"},
163
+ "strategy_wallet": {"address": "0x5678"},
164
+ },
165
+ simulation=True,
166
+ )
167
+ s.hyperliquid_adapter = mock_hyperliquid_adapter
168
+ s.ledger_adapter = ledger_adapter
169
+ s.balance_adapter = MagicMock()
170
+ s.balance_adapter.get_balance = AsyncMock(
171
+ return_value=(True, 0)
172
+ )
173
+ s.balance_adapter.move_from_main_wallet_to_strategy_wallet = AsyncMock(
174
+ return_value=(True, {})
175
+ )
176
+ s.balance_adapter.send_to_address = AsyncMock(
177
+ return_value=(True, {"tx_hash": "0x123"})
178
+ )
179
+ return s
180
+
181
+ @pytest.mark.asyncio
182
+ @pytest.mark.smoke
183
+ async def test_smoke(self, strategy):
184
+ """Smoke test: deposit → update → status → withdraw lifecycle."""
185
+ examples = load_examples()
186
+ smoke = examples["smoke"]
187
+
188
+ # Deposit
189
+ deposit_params = smoke.get("deposit", {})
190
+ success, msg = await strategy.deposit(**deposit_params)
191
+ assert success, f"Deposit failed: {msg}"
192
+
193
+ # Update
194
+ success, msg = await strategy.update()
195
+ assert success, f"Update failed: {msg}"
196
+
197
+ # Status
198
+ status = await strategy.status()
199
+ assert "portfolio_value" in status
200
+
201
+ # Withdraw
202
+ success, msg = await strategy.withdraw()
203
+ assert success, f"Withdraw failed: {msg}"
204
+
205
+ @pytest.mark.asyncio
206
+ async def test_deposit_minimum(self, strategy):
207
+ """Test minimum deposit validation."""
208
+ examples = load_examples()
209
+ min_fail = examples.get("min_deposit_fail", {})
210
+
211
+ if min_fail:
212
+ deposit_params = min_fail.get("deposit", {})
213
+ success, msg = await strategy.deposit(**deposit_params)
214
+
215
+ expect = min_fail.get("expect", {})
216
+ if expect.get("success") is False:
217
+ assert success is False, "Expected deposit to fail"
218
+
219
+ @pytest.mark.asyncio
220
+ async def test_update_without_deposit(self, strategy):
221
+ """Test update fails without deposit."""
222
+ success, msg = await strategy.update()
223
+ assert success is False
224
+ assert "No deposit" in msg
225
+
226
+ @pytest.mark.asyncio
227
+ async def test_withdraw_without_deposit(self, strategy):
228
+ """Test withdraw fails without deposit."""
229
+ success, msg = await strategy.withdraw()
230
+ assert success is False
231
+
232
+ @pytest.mark.asyncio
233
+ async def test_status(self, strategy):
234
+ """Test status returns expected fields."""
235
+ status = await strategy.status()
236
+ assert "portfolio_value" in status
237
+ assert "net_deposit" in status
238
+ assert "strategy_status" in status
239
+
240
+ @pytest.mark.asyncio
241
+ async def test_ledger_records_snapshot(self, strategy, tmp_path):
242
+ """Test that status() records a snapshot to the ledger."""
243
+ # Get status (should record snapshot)
244
+ status = await strategy.status()
245
+ assert status is not None
246
+
247
+ # Verify snapshot was written to temp ledger
248
+ snapshots_file = tmp_path / "snapshots.json"
249
+ assert snapshots_file.exists()
250
+
251
+ with open(snapshots_file) as f:
252
+ data = json.load(f)
253
+
254
+ assert len(data["snapshots"]) == 1
255
+ snapshot = data["snapshots"][0]
256
+ assert snapshot["wallet_address"] == "0x5678"
257
+ assert snapshot["portfolio_value"] == status["portfolio_value"]
258
+
259
+ def test_maintenance_rate(self):
260
+ """Test maintenance rate calculation."""
261
+ from wayfinder_paths.strategies.basis_trading_strategy.strategy import (
262
+ BasisTradingStrategy,
263
+ )
264
+
265
+ rate = BasisTradingStrategy.maintenance_rate_from_max_leverage(50)
266
+ assert rate == 0.01 # 0.5 / 50
267
+
268
+ rate = BasisTradingStrategy.maintenance_rate_from_max_leverage(10)
269
+ assert rate == 0.05 # 0.5 / 10
270
+
271
+ def test_rolling_min_sum(self, strategy):
272
+ """Test rolling minimum sum calculation."""
273
+ arr = [1, -2, 3, -4, 5]
274
+ result = strategy._rolling_min_sum(arr, 2)
275
+ assert result == -1 # min of [1-2, -2+3, 3-4, -4+5] = [-1, 1, -1, 1]
276
+
277
+ def test_z_from_conf(self, strategy):
278
+ """Test z-score calculation."""
279
+ z = strategy._z_from_conf(0.95)
280
+ assert 1.9 < z < 2.0 # ~1.96 for 95% two-sided confidence
281
+
282
+ z = strategy._z_from_conf(0.99)
283
+ assert 2.5 < z < 2.6 # ~2.576 for 99% two-sided confidence
284
+
285
+ def test_calculate_funding_stats(self, strategy):
286
+ """Test funding statistics calculation."""
287
+ hourly_funding = [0.0001, 0.0002, -0.0001, 0.0003, 0.0001]
288
+ stats = strategy._calculate_funding_stats(hourly_funding)
289
+
290
+ assert stats["points"] == 5
291
+ assert stats["mean_hourly"] > 0
292
+ assert stats["neg_hour_fraction"] == 0.2 # 1/5 negative
293
+
294
+ @pytest.mark.asyncio
295
+ async def test_build_batch_snapshot_and_filter(self, strategy):
296
+ snap = await strategy.build_batch_snapshot(
297
+ score_deposit_usdc=1000.0, bootstrap_sims=0
298
+ )
299
+ assert snap["kind"] == "basis_trading_batch_snapshot"
300
+ assert "hour_bucket_utc" in snap
301
+ assert isinstance(snap.get("candidates"), list)
302
+ assert snap["candidates"], "Expected at least one candidate in snapshot"
303
+
304
+ candidate = snap["candidates"][0]
305
+ assert "liquidity" in candidate
306
+ assert candidate["liquidity"]["max_order_usd"] > 0
307
+ assert isinstance(candidate.get("options"), list) and candidate["options"]
308
+
309
+ opps = strategy.opportunities_from_snapshot(snapshot=snap, deposit_usdc=1000.0)
310
+ assert opps, "Expected opportunities from snapshot"
311
+ assert opps[0]["selection"]["net_apy"] is not None
312
+
313
+ @pytest.mark.asyncio
314
+ async def test_get_undeployed_capital_empty(self, strategy):
315
+ """Test _get_undeployed_capital with no capital."""
316
+ perp_margin, spot_usdc = await strategy._get_undeployed_capital()
317
+ assert perp_margin == 0.0
318
+ assert spot_usdc == 0.0
319
+
320
+ @pytest.mark.asyncio
321
+ async def test_get_undeployed_capital_with_margin(
322
+ self, strategy, mock_hyperliquid_adapter
323
+ ):
324
+ """Test _get_undeployed_capital with withdrawable margin."""
325
+ mock_hyperliquid_adapter.get_user_state = AsyncMock(
326
+ return_value=(
327
+ True,
328
+ {
329
+ "marginSummary": {"accountValue": "100", "withdrawable": "50"},
330
+ "assetPositions": [],
331
+ },
332
+ )
333
+ )
334
+ mock_hyperliquid_adapter.get_spot_user_state = AsyncMock(
335
+ return_value=(
336
+ True,
337
+ {"balances": [{"coin": "USDC", "total": "25.5"}]},
338
+ )
339
+ )
340
+
341
+ perp_margin, spot_usdc = await strategy._get_undeployed_capital()
342
+ assert perp_margin == 50.0
343
+ assert spot_usdc == 25.5
344
+
345
+ @pytest.mark.asyncio
346
+ async def test_scale_up_position_no_position(self, strategy):
347
+ """Test _scale_up_position fails without existing position."""
348
+ success, msg = await strategy._scale_up_position(100.0)
349
+ assert success is False
350
+ assert "No position to scale up" in msg
351
+
352
+ @pytest.mark.asyncio
353
+ async def test_scale_up_position_below_minimum(
354
+ self, strategy, mock_hyperliquid_adapter
355
+ ):
356
+ """Test _scale_up_position rejects below minimum notional."""
357
+ from wayfinder_paths.strategies.basis_trading_strategy.strategy import (
358
+ BasisPosition,
359
+ )
360
+
361
+ strategy.current_position = BasisPosition(
362
+ coin="ETH",
363
+ spot_asset_id=10000,
364
+ perp_asset_id=1,
365
+ spot_amount=1.0,
366
+ perp_amount=1.0,
367
+ entry_price=2000.0,
368
+ leverage=2,
369
+ entry_timestamp=1700000000000,
370
+ funding_collected=0.0,
371
+ )
372
+
373
+ # Try to scale with $5 (below $10 minimum notional)
374
+ # With 2x leverage, order_usd = 5 * (2/3) = 3.33, below $10
375
+ success, msg = await strategy._scale_up_position(5.0)
376
+ assert success is True # Returns success=True but with message
377
+ assert "below minimum notional" in msg
378
+
379
+ @pytest.mark.asyncio
380
+ async def test_update_with_idle_capital_scales_up(
381
+ self, strategy, mock_hyperliquid_adapter
382
+ ):
383
+ """Test update() calls _scale_up_position when idle capital exists."""
384
+ from wayfinder_paths.strategies.basis_trading_strategy.strategy import (
385
+ BasisPosition,
386
+ )
387
+
388
+ strategy.deposit_amount = 100.0
389
+ strategy.current_position = BasisPosition(
390
+ coin="ETH",
391
+ spot_asset_id=10000,
392
+ perp_asset_id=1,
393
+ spot_amount=1.0,
394
+ perp_amount=1.0,
395
+ entry_price=2000.0,
396
+ leverage=2,
397
+ entry_timestamp=1700000000000,
398
+ funding_collected=0.0,
399
+ )
400
+
401
+ # Mock user state with perp position and idle capital
402
+ # totalNtlPos represents position notional value, set high to avoid rebalance trigger
403
+ # unused_usd = accountValue - totalNtlPos = 120 - 112 = 8
404
+ # threshold for rebalance = epsilon * 2 = max(5, 0.02 * 100) * 2 = 10
405
+ # 8 < 10 so no rebalance
406
+ # total_idle = withdrawable (12) + spot_usdc (8) = 20 > min_deploy (5) so will scale up
407
+ # order_usd = 20 * (2/3) = 13.33 > MIN_NOTIONAL_USD (10)
408
+ mock_hyperliquid_adapter.get_user_state = AsyncMock(
409
+ return_value=(
410
+ True,
411
+ {
412
+ "marginSummary": {
413
+ "accountValue": "120",
414
+ "withdrawable": "12",
415
+ "totalNtlPos": "112", # Deployed capital
416
+ },
417
+ "assetPositions": [
418
+ {
419
+ "position": {
420
+ "coin": "ETH",
421
+ "szi": "-1.0",
422
+ "leverage": {"value": "2"},
423
+ "liquidationPx": "2500",
424
+ "entryPx": "2000",
425
+ }
426
+ }
427
+ ],
428
+ },
429
+ )
430
+ )
431
+ # Include ETH spot balance for leg balance check, plus USDC for idle capital
432
+ mock_hyperliquid_adapter.get_spot_user_state = AsyncMock(
433
+ return_value=(
434
+ True,
435
+ {
436
+ "balances": [
437
+ {"coin": "ETH", "total": "1.0"},
438
+ {"coin": "USDC", "total": "8"},
439
+ ]
440
+ },
441
+ )
442
+ )
443
+ mock_hyperliquid_adapter.get_valid_order_size = MagicMock(
444
+ side_effect=lambda aid, sz: sz
445
+ )
446
+ mock_hyperliquid_adapter.transfer_perp_to_spot = AsyncMock(
447
+ return_value=(True, "ok")
448
+ )
449
+ mock_hyperliquid_adapter.get_open_orders = AsyncMock(
450
+ return_value=(
451
+ True,
452
+ [
453
+ {
454
+ "coin": "ETH",
455
+ "orderType": "trigger",
456
+ "triggerPx": "2400",
457
+ "sz": "1.0",
458
+ "oid": 123,
459
+ }
460
+ ],
461
+ )
462
+ )
463
+
464
+ # Mock the paired filler to avoid actual execution
465
+ with patch(
466
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.PairedFiller"
467
+ ) as mock_filler_class:
468
+ mock_filler = MagicMock()
469
+ mock_filler.fill_pair_units = AsyncMock(
470
+ return_value=(0.5, 0.5, 1000.0, 1000.0, [], [])
471
+ )
472
+ mock_filler_class.return_value = mock_filler
473
+
474
+ success, msg = await strategy.update()
475
+
476
+ # Should have called fill_pair_units to scale up
477
+ assert mock_filler.fill_pair_units.called
478
+ assert success is True
479
+
480
+ @pytest.mark.asyncio
481
+ async def test_ensure_builder_fee_approved_already_approved(
482
+ self, mock_hyperliquid_adapter, ledger_adapter
483
+ ):
484
+ """Test ensure_builder_fee_approved when already approved."""
485
+ with patch(
486
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.HyperliquidAdapter",
487
+ return_value=mock_hyperliquid_adapter,
488
+ ):
489
+ with patch(
490
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.BalanceAdapter"
491
+ ):
492
+ with patch(
493
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.TokenAdapter"
494
+ ):
495
+ with patch(
496
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.LedgerAdapter",
497
+ return_value=ledger_adapter,
498
+ ):
499
+ with patch(
500
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.WalletManager"
501
+ ):
502
+ from wayfinder_paths.strategies.basis_trading_strategy.strategy import (
503
+ BasisTradingStrategy,
504
+ )
505
+
506
+ s = BasisTradingStrategy(
507
+ config={
508
+ "main_wallet": {"address": "0x1234"},
509
+ "strategy_wallet": {"address": "0x5678"},
510
+ },
511
+ simulation=False, # Not simulation mode
512
+ )
513
+ s.hyperliquid_adapter = mock_hyperliquid_adapter
514
+ s.ledger_adapter = ledger_adapter
515
+
516
+ # Mock get_max_builder_fee returning sufficient approval
517
+ mock_hyperliquid_adapter.get_max_builder_fee = AsyncMock(
518
+ return_value=(
519
+ True,
520
+ 30,
521
+ ) # Already approved for 30 tenths bp
522
+ )
523
+ mock_hyperliquid_adapter.approve_builder_fee = AsyncMock(
524
+ return_value=(True, {"status": "ok"})
525
+ )
526
+
527
+ success, msg = await s.ensure_builder_fee_approved()
528
+ assert success is True
529
+ assert "already approved" in msg.lower()
530
+ # Should not have called approve_builder_fee
531
+ mock_hyperliquid_adapter.approve_builder_fee.assert_not_called()
532
+
533
+ @pytest.mark.asyncio
534
+ async def test_ensure_builder_fee_approved_needs_approval(
535
+ self, mock_hyperliquid_adapter, ledger_adapter
536
+ ):
537
+ """Test ensure_builder_fee_approved when approval is needed."""
538
+ with patch(
539
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.HyperliquidAdapter",
540
+ return_value=mock_hyperliquid_adapter,
541
+ ):
542
+ with patch(
543
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.BalanceAdapter"
544
+ ):
545
+ with patch(
546
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.TokenAdapter"
547
+ ):
548
+ with patch(
549
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.LedgerAdapter",
550
+ return_value=ledger_adapter,
551
+ ):
552
+ with patch(
553
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.WalletManager"
554
+ ):
555
+ from wayfinder_paths.strategies.basis_trading_strategy.strategy import (
556
+ BasisTradingStrategy,
557
+ )
558
+
559
+ s = BasisTradingStrategy(
560
+ config={
561
+ "main_wallet": {"address": "0x1234"},
562
+ "strategy_wallet": {"address": "0x5678"},
563
+ },
564
+ simulation=False, # Not simulation mode
565
+ )
566
+ s.hyperliquid_adapter = mock_hyperliquid_adapter
567
+ s.ledger_adapter = ledger_adapter
568
+
569
+ # Mock get_max_builder_fee returning insufficient approval
570
+ mock_hyperliquid_adapter.get_max_builder_fee = AsyncMock(
571
+ return_value=(True, 0) # Not approved yet
572
+ )
573
+ mock_hyperliquid_adapter.approve_builder_fee = AsyncMock(
574
+ return_value=(True, {"status": "ok"})
575
+ )
576
+
577
+ success, msg = await s.ensure_builder_fee_approved()
578
+ assert success is True
579
+ assert "approved" in msg.lower()
580
+ # Should have called approve_builder_fee
581
+ mock_hyperliquid_adapter.approve_builder_fee.assert_called_once()
582
+
583
+ @pytest.mark.asyncio
584
+ async def test_ensure_builder_fee_simulation_mode(
585
+ self, mock_hyperliquid_adapter, ledger_adapter
586
+ ):
587
+ """Test ensure_builder_fee_approved in simulation mode."""
588
+ with patch(
589
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.HyperliquidAdapter",
590
+ return_value=mock_hyperliquid_adapter,
591
+ ):
592
+ with patch(
593
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.BalanceAdapter"
594
+ ):
595
+ with patch(
596
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.TokenAdapter"
597
+ ):
598
+ with patch(
599
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.LedgerAdapter",
600
+ return_value=ledger_adapter,
601
+ ):
602
+ with patch(
603
+ "wayfinder_paths.strategies.basis_trading_strategy.strategy.WalletManager"
604
+ ):
605
+ from wayfinder_paths.strategies.basis_trading_strategy.strategy import (
606
+ BasisTradingStrategy,
607
+ )
608
+
609
+ s = BasisTradingStrategy(
610
+ config={
611
+ "main_wallet": {"address": "0x1234"},
612
+ "strategy_wallet": {"address": "0x5678"},
613
+ },
614
+ simulation=True, # Simulation mode
615
+ )
616
+ s.hyperliquid_adapter = mock_hyperliquid_adapter
617
+ s.ledger_adapter = ledger_adapter
618
+
619
+ success, msg = await s.ensure_builder_fee_approved()
620
+ assert success is True
621
+ assert "simulation" in msg.lower()
622
+
623
+ @pytest.mark.asyncio
624
+ async def test_portfolio_value_includes_spot_holdings(
625
+ self, strategy, mock_hyperliquid_adapter
626
+ ):
627
+ """Portfolio value should include non-USDC spot holdings."""
628
+ # Perp account has $100
629
+ mock_hyperliquid_adapter.get_user_state = AsyncMock(
630
+ return_value=(
631
+ True,
632
+ {"marginSummary": {"accountValue": "100"}, "assetPositions": []},
633
+ )
634
+ )
635
+ # Spot has 50 USDC + 0.5 ETH
636
+ mock_hyperliquid_adapter.get_spot_user_state = AsyncMock(
637
+ return_value=(
638
+ True,
639
+ {
640
+ "balances": [
641
+ {"coin": "USDC", "total": "50"},
642
+ {"coin": "ETH", "total": "0.5"},
643
+ ]
644
+ },
645
+ )
646
+ )
647
+ # ETH price is $2000
648
+ mock_hyperliquid_adapter.get_all_mid_prices = AsyncMock(
649
+ return_value=(True, {"ETH": 2000.0, "BTC": 50000.0})
650
+ )
651
+
652
+ total, hl_value, vault_value = await strategy._get_total_portfolio_value()
653
+ # 100 (perp) + 50 (USDC) + 0.5*2000 (ETH) = 1150
654
+ assert hl_value == 1150.0
655
+ assert total == 1150.0 # No vault balance
656
+
657
+ @pytest.mark.asyncio
658
+ async def test_portfolio_value_usdc_only(self, strategy, mock_hyperliquid_adapter):
659
+ """Portfolio value with only USDC spot balance."""
660
+ mock_hyperliquid_adapter.get_user_state = AsyncMock(
661
+ return_value=(
662
+ True,
663
+ {"marginSummary": {"accountValue": "0"}, "assetPositions": []},
664
+ )
665
+ )
666
+ mock_hyperliquid_adapter.get_spot_user_state = AsyncMock(
667
+ return_value=(
668
+ True,
669
+ {"balances": [{"coin": "USDC", "total": "100"}]},
670
+ )
671
+ )
672
+ # Should not need mid prices when only USDC
673
+ mock_hyperliquid_adapter.get_all_mid_prices = AsyncMock(return_value=(True, {}))
674
+
675
+ total, hl_value, vault_value = await strategy._get_total_portfolio_value()
676
+ assert hl_value == 100.0
677
+ assert total == 100.0
678
+
679
+ @pytest.mark.asyncio
680
+ async def test_withdraw_detects_spot_usdc(self, strategy, mock_hyperliquid_adapter):
681
+ """Withdraw should detect funds in spot USDC (not perp margin)."""
682
+ # Perp is empty
683
+ mock_hyperliquid_adapter.get_user_state = AsyncMock(
684
+ return_value=(
685
+ True,
686
+ {
687
+ "marginSummary": {"accountValue": "0"},
688
+ "withdrawable": "0",
689
+ "assetPositions": [],
690
+ },
691
+ )
692
+ )
693
+ # Spot has 100 USDC
694
+ mock_hyperliquid_adapter.get_spot_user_state = AsyncMock(
695
+ return_value=(
696
+ True,
697
+ {"balances": [{"coin": "USDC", "total": "100"}]},
698
+ )
699
+ )
700
+
701
+ success, msg = await strategy.withdraw()
702
+ # Should NOT return "Nothing to withdraw" since there's USDC in spot
703
+ assert "Nothing to withdraw" not in msg
704
+
705
+ @pytest.mark.asyncio
706
+ async def test_update_detects_hl_balance_when_deposit_zero(
707
+ self, strategy, mock_hyperliquid_adapter
708
+ ):
709
+ """Update should detect Hyperliquid balance when deposit_amount is 0."""
710
+ strategy.deposit_amount = 0
711
+
712
+ # Hyperliquid has $50 in perp account
713
+ mock_hyperliquid_adapter.get_user_state = AsyncMock(
714
+ return_value=(
715
+ True,
716
+ {
717
+ "marginSummary": {"accountValue": "50", "withdrawable": "50"},
718
+ "assetPositions": [],
719
+ },
720
+ )
721
+ )
722
+
723
+ # Run update - it should detect the balance
724
+ await strategy.update()
725
+
726
+ # deposit_amount should now be set from detected balance
727
+ assert strategy.deposit_amount == 50.0