wayfinder-paths 0.1.7__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 (149) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +399 -0
  2. wayfinder_paths/__init__.py +22 -0
  3. wayfinder_paths/abis/generic/erc20.json +383 -0
  4. wayfinder_paths/adapters/__init__.py +0 -0
  5. wayfinder_paths/adapters/balance_adapter/README.md +94 -0
  6. wayfinder_paths/adapters/balance_adapter/adapter.py +238 -0
  7. wayfinder_paths/adapters/balance_adapter/examples.json +6 -0
  8. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  9. wayfinder_paths/adapters/balance_adapter/test_adapter.py +59 -0
  10. wayfinder_paths/adapters/brap_adapter/README.md +249 -0
  11. wayfinder_paths/adapters/brap_adapter/__init__.py +7 -0
  12. wayfinder_paths/adapters/brap_adapter/adapter.py +726 -0
  13. wayfinder_paths/adapters/brap_adapter/examples.json +175 -0
  14. wayfinder_paths/adapters/brap_adapter/manifest.yaml +11 -0
  15. wayfinder_paths/adapters/brap_adapter/test_adapter.py +286 -0
  16. wayfinder_paths/adapters/hyperlend_adapter/__init__.py +7 -0
  17. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +305 -0
  18. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +10 -0
  19. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +274 -0
  20. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
  21. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
  22. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
  23. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
  24. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
  25. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
  26. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
  27. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
  28. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
  29. wayfinder_paths/adapters/ledger_adapter/README.md +145 -0
  30. wayfinder_paths/adapters/ledger_adapter/__init__.py +7 -0
  31. wayfinder_paths/adapters/ledger_adapter/adapter.py +289 -0
  32. wayfinder_paths/adapters/ledger_adapter/examples.json +137 -0
  33. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +11 -0
  34. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +205 -0
  35. wayfinder_paths/adapters/pool_adapter/README.md +206 -0
  36. wayfinder_paths/adapters/pool_adapter/__init__.py +7 -0
  37. wayfinder_paths/adapters/pool_adapter/adapter.py +282 -0
  38. wayfinder_paths/adapters/pool_adapter/examples.json +143 -0
  39. wayfinder_paths/adapters/pool_adapter/manifest.yaml +10 -0
  40. wayfinder_paths/adapters/pool_adapter/test_adapter.py +220 -0
  41. wayfinder_paths/adapters/token_adapter/README.md +101 -0
  42. wayfinder_paths/adapters/token_adapter/__init__.py +3 -0
  43. wayfinder_paths/adapters/token_adapter/adapter.py +96 -0
  44. wayfinder_paths/adapters/token_adapter/examples.json +26 -0
  45. wayfinder_paths/adapters/token_adapter/manifest.yaml +6 -0
  46. wayfinder_paths/adapters/token_adapter/test_adapter.py +125 -0
  47. wayfinder_paths/config.example.json +22 -0
  48. wayfinder_paths/conftest.py +31 -0
  49. wayfinder_paths/core/__init__.py +18 -0
  50. wayfinder_paths/core/adapters/BaseAdapter.py +65 -0
  51. wayfinder_paths/core/adapters/__init__.py +5 -0
  52. wayfinder_paths/core/adapters/base.py +5 -0
  53. wayfinder_paths/core/adapters/models.py +46 -0
  54. wayfinder_paths/core/analytics/__init__.py +11 -0
  55. wayfinder_paths/core/analytics/bootstrap.py +57 -0
  56. wayfinder_paths/core/analytics/stats.py +48 -0
  57. wayfinder_paths/core/analytics/test_analytics.py +170 -0
  58. wayfinder_paths/core/clients/AuthClient.py +83 -0
  59. wayfinder_paths/core/clients/BRAPClient.py +109 -0
  60. wayfinder_paths/core/clients/ClientManager.py +210 -0
  61. wayfinder_paths/core/clients/HyperlendClient.py +192 -0
  62. wayfinder_paths/core/clients/LedgerClient.py +443 -0
  63. wayfinder_paths/core/clients/PoolClient.py +128 -0
  64. wayfinder_paths/core/clients/SimulationClient.py +192 -0
  65. wayfinder_paths/core/clients/TokenClient.py +89 -0
  66. wayfinder_paths/core/clients/TransactionClient.py +63 -0
  67. wayfinder_paths/core/clients/WalletClient.py +94 -0
  68. wayfinder_paths/core/clients/WayfinderClient.py +269 -0
  69. wayfinder_paths/core/clients/__init__.py +48 -0
  70. wayfinder_paths/core/clients/protocols.py +392 -0
  71. wayfinder_paths/core/clients/sdk_example.py +110 -0
  72. wayfinder_paths/core/config.py +458 -0
  73. wayfinder_paths/core/constants/__init__.py +26 -0
  74. wayfinder_paths/core/constants/base.py +42 -0
  75. wayfinder_paths/core/constants/erc20_abi.py +118 -0
  76. wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
  77. wayfinder_paths/core/engine/StrategyJob.py +188 -0
  78. wayfinder_paths/core/engine/__init__.py +5 -0
  79. wayfinder_paths/core/engine/manifest.py +97 -0
  80. wayfinder_paths/core/services/__init__.py +0 -0
  81. wayfinder_paths/core/services/base.py +179 -0
  82. wayfinder_paths/core/services/local_evm_txn.py +430 -0
  83. wayfinder_paths/core/services/local_token_txn.py +231 -0
  84. wayfinder_paths/core/services/web3_service.py +45 -0
  85. wayfinder_paths/core/settings.py +61 -0
  86. wayfinder_paths/core/strategies/Strategy.py +280 -0
  87. wayfinder_paths/core/strategies/__init__.py +5 -0
  88. wayfinder_paths/core/strategies/base.py +7 -0
  89. wayfinder_paths/core/strategies/descriptors.py +81 -0
  90. wayfinder_paths/core/utils/__init__.py +1 -0
  91. wayfinder_paths/core/utils/evm_helpers.py +206 -0
  92. wayfinder_paths/core/utils/wallets.py +77 -0
  93. wayfinder_paths/core/wallets/README.md +91 -0
  94. wayfinder_paths/core/wallets/WalletManager.py +56 -0
  95. wayfinder_paths/core/wallets/__init__.py +7 -0
  96. wayfinder_paths/policies/enso.py +17 -0
  97. wayfinder_paths/policies/erc20.py +34 -0
  98. wayfinder_paths/policies/evm.py +21 -0
  99. wayfinder_paths/policies/hyper_evm.py +19 -0
  100. wayfinder_paths/policies/hyperlend.py +12 -0
  101. wayfinder_paths/policies/hyperliquid.py +30 -0
  102. wayfinder_paths/policies/moonwell.py +54 -0
  103. wayfinder_paths/policies/prjx.py +30 -0
  104. wayfinder_paths/policies/util.py +27 -0
  105. wayfinder_paths/run_strategy.py +411 -0
  106. wayfinder_paths/scripts/__init__.py +0 -0
  107. wayfinder_paths/scripts/create_strategy.py +181 -0
  108. wayfinder_paths/scripts/make_wallets.py +169 -0
  109. wayfinder_paths/scripts/run_strategy.py +124 -0
  110. wayfinder_paths/scripts/validate_manifests.py +213 -0
  111. wayfinder_paths/strategies/__init__.py +0 -0
  112. wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
  113. wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
  114. wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
  115. wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
  116. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
  117. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
  118. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
  119. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
  120. wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
  121. wayfinder_paths/strategies/config.py +85 -0
  122. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +100 -0
  123. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +8 -0
  124. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
  125. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2270 -0
  126. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +352 -0
  127. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +96 -0
  128. wayfinder_paths/strategies/stablecoin_yield_strategy/examples.json +17 -0
  129. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
  130. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1810 -0
  131. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +520 -0
  132. wayfinder_paths/templates/adapter/README.md +105 -0
  133. wayfinder_paths/templates/adapter/adapter.py +26 -0
  134. wayfinder_paths/templates/adapter/examples.json +8 -0
  135. wayfinder_paths/templates/adapter/manifest.yaml +6 -0
  136. wayfinder_paths/templates/adapter/test_adapter.py +49 -0
  137. wayfinder_paths/templates/strategy/README.md +153 -0
  138. wayfinder_paths/templates/strategy/examples.json +11 -0
  139. wayfinder_paths/templates/strategy/manifest.yaml +8 -0
  140. wayfinder_paths/templates/strategy/strategy.py +57 -0
  141. wayfinder_paths/templates/strategy/test_strategy.py +197 -0
  142. wayfinder_paths/tests/__init__.py +0 -0
  143. wayfinder_paths/tests/test_smoke_manifest.py +48 -0
  144. wayfinder_paths/tests/test_test_coverage.py +212 -0
  145. wayfinder_paths/tests/test_utils.py +64 -0
  146. wayfinder_paths-0.1.7.dist-info/LICENSE +21 -0
  147. wayfinder_paths-0.1.7.dist-info/METADATA +777 -0
  148. wayfinder_paths-0.1.7.dist-info/RECORD +149 -0
  149. wayfinder_paths-0.1.7.dist-info/WHEEL +4 -0
@@ -0,0 +1,126 @@
1
+ """Tests for HyperliquidAdapter."""
2
+
3
+ from types import SimpleNamespace
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+
9
+ class TestHyperliquidAdapter:
10
+ """Tests for HyperliquidAdapter functionality."""
11
+
12
+ @pytest.fixture
13
+ def mock_info(self):
14
+ """Create a mock Info client."""
15
+ mock = MagicMock()
16
+ mock.meta_and_asset_ctxs.return_value = [
17
+ {"universe": [{"name": "BTC"}, {"name": "ETH"}]},
18
+ [{"funding": "0.0001"}],
19
+ ]
20
+ mock.spot_meta = {"tokens": [], "universe": []}
21
+ mock.funding_history.return_value = [
22
+ {"time": 1700000000000, "coin": "ETH", "fundingRate": "0.0001"}
23
+ ]
24
+ mock.candles_snapshot.return_value = [
25
+ {"t": 1700000000000, "o": "2000", "h": "2050", "l": "1980", "c": "2020"}
26
+ ]
27
+ mock.l2_snapshot.return_value = {
28
+ "levels": [[{"px": "2000", "sz": "10", "n": 5}]]
29
+ }
30
+ mock.user_state.return_value = {"assetPositions": [], "crossMarginSummary": {}}
31
+ mock.spot_user_state.return_value = {"balances": []}
32
+ mock.post.return_value = []
33
+ mock.asset_to_sz_decimals = {0: 4, 1: 3, 10000: 6}
34
+ mock.coin_to_asset = {"BTC": 0, "ETH": 1}
35
+ return mock
36
+
37
+ @pytest.fixture
38
+ def mock_constants(self):
39
+ """Create mock constants module."""
40
+ return SimpleNamespace(MAINNET_API_URL="https://api.hyperliquid.xyz")
41
+
42
+ @pytest.fixture
43
+ def adapter(self, mock_info, mock_constants):
44
+ """Create adapter with mocked Info client."""
45
+ with patch(
46
+ "wayfinder_paths.adapters.hyperliquid_adapter.adapter.Info",
47
+ return_value=mock_info,
48
+ ):
49
+ with patch(
50
+ "wayfinder_paths.adapters.hyperliquid_adapter.adapter.constants",
51
+ mock_constants,
52
+ ):
53
+ with patch(
54
+ "wayfinder_paths.adapters.hyperliquid_adapter.adapter.HYPERLIQUID_AVAILABLE",
55
+ True,
56
+ ):
57
+ from wayfinder_paths.adapters.hyperliquid_adapter.adapter import (
58
+ HyperliquidAdapter,
59
+ )
60
+
61
+ adapter = HyperliquidAdapter(config={})
62
+ adapter.info = mock_info
63
+ return adapter
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_connect(self, adapter):
67
+ """Test connection verification."""
68
+ result = await adapter.connect()
69
+ assert result is True
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_get_meta_and_asset_ctxs(self, adapter):
73
+ """Test fetching market metadata."""
74
+ success, data = await adapter.get_meta_and_asset_ctxs()
75
+ assert success is True
76
+ assert "universe" in data[0]
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_get_spot_meta(self, adapter):
80
+ """Test fetching spot metadata."""
81
+ success, data = await adapter.get_spot_meta()
82
+ assert success is True
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_get_funding_history(self, adapter):
86
+ """Test fetching funding history."""
87
+ success, data = await adapter.get_funding_history("ETH", 1700000000000)
88
+ assert success is True
89
+ assert isinstance(data, list)
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_get_candles(self, adapter):
93
+ """Test fetching candle data."""
94
+ success, data = await adapter.get_candles("ETH", "1h", 1700000000000)
95
+ assert success is True
96
+ assert isinstance(data, list)
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_get_l2_book(self, adapter):
100
+ """Test fetching order book."""
101
+ success, data = await adapter.get_l2_book("ETH")
102
+ assert success is True
103
+ assert "levels" in data
104
+
105
+ @pytest.mark.asyncio
106
+ async def test_get_user_state(self, adapter):
107
+ """Test fetching user state."""
108
+ success, data = await adapter.get_user_state("0x1234")
109
+ assert success is True
110
+ assert "assetPositions" in data
111
+
112
+ @pytest.mark.asyncio
113
+ async def test_health_check(self, adapter):
114
+ """Test health check."""
115
+ result = await adapter.health_check()
116
+ assert result["status"] == "healthy"
117
+
118
+ def test_get_sz_decimals(self, adapter):
119
+ """Test getting size decimals."""
120
+ decimals = adapter.get_sz_decimals(0)
121
+ assert decimals == 4
122
+
123
+ def test_get_sz_decimals_unknown_asset(self, adapter):
124
+ """Test error on unknown asset."""
125
+ with pytest.raises(ValueError, match="Unknown asset_id"):
126
+ adapter.get_sz_decimals(99999)
@@ -0,0 +1,219 @@
1
+ """
2
+ Live API tests for HyperliquidAdapter.
3
+
4
+ These tests hit the real Hyperliquid API to verify:
5
+ - Spot asset ID resolution (PURR, ETH, BTC, HYPE)
6
+ - Perp asset ID resolution
7
+ - API connectivity
8
+
9
+ Run with: pytest wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py -v
10
+ """
11
+
12
+ import pytest
13
+
14
+ from wayfinder_paths.adapters.hyperliquid_adapter.adapter import HyperliquidAdapter
15
+
16
+
17
+ @pytest.fixture
18
+ def live_adapter():
19
+ """Create adapter connected to real Hyperliquid API."""
20
+ return HyperliquidAdapter(config={}, simulation=True)
21
+
22
+
23
+ class TestSpotAssetIDs:
24
+ """Test spot asset ID resolution against live API."""
25
+
26
+ @pytest.mark.asyncio
27
+ async def test_get_spot_assets_returns_dict(self, live_adapter):
28
+ """Verify get_spot_assets returns a populated dict."""
29
+ success, spot_assets = await live_adapter.get_spot_assets()
30
+
31
+ assert success is True
32
+ assert isinstance(spot_assets, dict)
33
+ assert len(spot_assets) > 0
34
+
35
+ @pytest.mark.asyncio
36
+ async def test_purr_spot_asset_id(self, live_adapter):
37
+ """PURR/USDC should be the first spot pair (index 0 + 10000 = 10000)."""
38
+ success, spot_assets = await live_adapter.get_spot_assets()
39
+
40
+ assert success is True
41
+ assert "PURR/USDC" in spot_assets
42
+ assert spot_assets["PURR/USDC"] == 10000
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_hype_spot_asset_id(self, live_adapter):
46
+ """HYPE/USDC should have asset ID 10107."""
47
+ success, spot_assets = await live_adapter.get_spot_assets()
48
+
49
+ assert success is True
50
+ assert "HYPE/USDC" in spot_assets
51
+ # HYPE is index 107, so asset_id = 10107
52
+ assert spot_assets["HYPE/USDC"] == 10107
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_eth_spot_exists(self, live_adapter):
56
+ """ETH/USDC spot pair should exist."""
57
+ success, spot_assets = await live_adapter.get_spot_assets()
58
+
59
+ assert success is True
60
+ # ETH spot may have different naming, check common variants
61
+ eth_pairs = [k for k in spot_assets if "ETH" in k and "USDC" in k]
62
+ assert len(eth_pairs) > 0, (
63
+ f"No ETH/USDC spot found. Available: {list(spot_assets.keys())[:20]}"
64
+ )
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_btc_spot_exists(self, live_adapter):
68
+ """BTC/USDC spot pair should exist."""
69
+ success, spot_assets = await live_adapter.get_spot_assets()
70
+
71
+ assert success is True
72
+ # BTC spot may have different naming
73
+ btc_pairs = [k for k in spot_assets if "BTC" in k and "USDC" in k]
74
+ assert len(btc_pairs) > 0, (
75
+ f"No BTC/USDC spot found. Available: {list(spot_assets.keys())[:20]}"
76
+ )
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_spot_asset_ids_are_valid(self, live_adapter):
80
+ """All spot asset IDs should be >= 10000."""
81
+ success, spot_assets = await live_adapter.get_spot_assets()
82
+
83
+ assert success is True
84
+ for name, asset_id in spot_assets.items():
85
+ assert asset_id >= 10000, f"{name} has invalid asset_id {asset_id}"
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_get_spot_asset_id_helper(self, live_adapter):
89
+ """Test synchronous helper after cache is populated."""
90
+ # First populate cache
91
+ success, _ = await live_adapter.get_spot_assets()
92
+ assert success is True
93
+
94
+ # Now use sync helper
95
+ purr_id = live_adapter.get_spot_asset_id("PURR", "USDC")
96
+ assert purr_id == 10000
97
+
98
+ hype_id = live_adapter.get_spot_asset_id("HYPE", "USDC")
99
+ assert hype_id == 10107
100
+
101
+ # Non-existent should return None
102
+ fake_id = live_adapter.get_spot_asset_id("FAKECOIN", "USDC")
103
+ assert fake_id is None
104
+
105
+
106
+ class TestPerpAssetIDs:
107
+ """Test perp asset ID resolution against live API."""
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_coin_to_asset_mapping(self, live_adapter):
111
+ """Verify perp coin to asset mapping is populated."""
112
+ coin_to_asset = live_adapter.coin_to_asset
113
+
114
+ assert isinstance(coin_to_asset, dict)
115
+ assert len(coin_to_asset) > 0
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_btc_perp_asset_id(self, live_adapter):
119
+ """BTC perp should be asset_id 0."""
120
+ coin_to_asset = live_adapter.coin_to_asset
121
+
122
+ assert "BTC" in coin_to_asset
123
+ assert coin_to_asset["BTC"] == 0
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_eth_perp_asset_id(self, live_adapter):
127
+ """ETH perp should be asset_id 1."""
128
+ coin_to_asset = live_adapter.coin_to_asset
129
+
130
+ assert "ETH" in coin_to_asset
131
+ assert coin_to_asset["ETH"] == 1
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_hype_perp_exists(self, live_adapter):
135
+ """HYPE perp should exist with valid asset_id."""
136
+ coin_to_asset = live_adapter.coin_to_asset
137
+
138
+ assert "HYPE" in coin_to_asset
139
+ assert coin_to_asset["HYPE"] < 10000 # Perp IDs are < 10000
140
+
141
+
142
+ class TestSpotMetaStructure:
143
+ """Test spot_meta API response structure."""
144
+
145
+ @pytest.mark.asyncio
146
+ async def test_spot_meta_has_tokens(self, live_adapter):
147
+ """Spot meta should have tokens array."""
148
+ success, spot_meta = await live_adapter.get_spot_meta()
149
+
150
+ assert success is True
151
+ assert "tokens" in spot_meta
152
+ assert isinstance(spot_meta["tokens"], list)
153
+ assert len(spot_meta["tokens"]) > 0
154
+
155
+ @pytest.mark.asyncio
156
+ async def test_spot_meta_has_universe(self, live_adapter):
157
+ """Spot meta should have universe array with pairs."""
158
+ success, spot_meta = await live_adapter.get_spot_meta()
159
+
160
+ assert success is True
161
+ assert "universe" in spot_meta
162
+ assert isinstance(spot_meta["universe"], list)
163
+ assert len(spot_meta["universe"]) > 0
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_spot_universe_pair_structure(self, live_adapter):
167
+ """Each spot universe entry should have tokens and index."""
168
+ success, spot_meta = await live_adapter.get_spot_meta()
169
+
170
+ assert success is True
171
+ for pair in spot_meta["universe"][:5]: # Check first 5
172
+ assert "tokens" in pair, f"Missing tokens in {pair}"
173
+ assert "index" in pair, f"Missing index in {pair}"
174
+ assert len(pair["tokens"]) >= 2, f"Invalid tokens in {pair}"
175
+
176
+
177
+ class TestL2BookResolution:
178
+ """Test that spot asset IDs work with L2 book API."""
179
+
180
+ @pytest.mark.asyncio
181
+ async def test_purr_spot_l2_book(self, live_adapter):
182
+ """PURR/USDC (10000) should return valid L2 book."""
183
+ success, book = await live_adapter.get_spot_l2_book(10000)
184
+
185
+ assert success is True
186
+ assert "levels" in book
187
+
188
+ @pytest.mark.asyncio
189
+ async def test_hype_spot_l2_book(self, live_adapter):
190
+ """HYPE/USDC (10107) should return valid L2 book."""
191
+ success, book = await live_adapter.get_spot_l2_book(10107)
192
+
193
+ assert success is True
194
+ assert "levels" in book
195
+
196
+
197
+ class TestSzDecimals:
198
+ """Test size decimals resolution for spot assets."""
199
+
200
+ @pytest.mark.asyncio
201
+ async def test_spot_sz_decimals(self, live_adapter):
202
+ """Spot assets should have valid sz_decimals."""
203
+ # HYPE spot = 10107
204
+ decimals = live_adapter.get_sz_decimals(10107)
205
+ assert isinstance(decimals, int)
206
+ assert decimals >= 0
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_perp_sz_decimals(self, live_adapter):
210
+ """Perp assets should have valid sz_decimals."""
211
+ # BTC perp = 0
212
+ decimals = live_adapter.get_sz_decimals(0)
213
+ assert isinstance(decimals, int)
214
+ assert decimals >= 0
215
+
216
+ # ETH perp = 1
217
+ decimals = live_adapter.get_sz_decimals(1)
218
+ assert isinstance(decimals, int)
219
+ assert decimals >= 0
@@ -0,0 +1,220 @@
1
+ """Tests for Hyperliquid adapter utility functions."""
2
+
3
+ import pytest
4
+
5
+ from wayfinder_paths.adapters.hyperliquid_adapter.utils import (
6
+ normalize_l2_book,
7
+ round_size_for_asset,
8
+ size_step,
9
+ spot_index_from_asset_id,
10
+ sz_decimals_for_asset,
11
+ usd_depth_in_band,
12
+ )
13
+
14
+
15
+ class TestSpotIndexFromAssetId:
16
+ """Tests for spot_index_from_asset_id function."""
17
+
18
+ def test_valid_spot_id(self):
19
+ """Valid spot asset IDs (>=10000) should return index."""
20
+ assert spot_index_from_asset_id(10000) == 0
21
+ assert spot_index_from_asset_id(10001) == 1
22
+ assert spot_index_from_asset_id(10107) == 107
23
+
24
+ def test_rejects_perp_id(self):
25
+ """Perp asset IDs (<10000) should raise ValueError."""
26
+ with pytest.raises(ValueError, match="Expected spot asset_id >= 10000"):
27
+ spot_index_from_asset_id(0)
28
+
29
+ with pytest.raises(ValueError, match="Expected spot asset_id >= 10000"):
30
+ spot_index_from_asset_id(9999)
31
+
32
+
33
+ class TestNormalizeL2Book:
34
+ """Tests for normalize_l2_book function."""
35
+
36
+ def test_levels_format(self):
37
+ """Should handle Hyperliquid 'levels' format (nested arrays)."""
38
+ raw = {
39
+ "levels": [
40
+ [{"px": "100.5", "sz": "10"}], # bids
41
+ [{"px": "101.0", "sz": "5"}], # asks
42
+ ],
43
+ "midPx": "100.75",
44
+ }
45
+ result = normalize_l2_book(raw)
46
+
47
+ assert result["bids"] == [(100.5, 10.0)]
48
+ assert result["asks"] == [(101.0, 5.0)]
49
+ assert result["midPx"] == 100.75
50
+
51
+ def test_bids_asks_format(self):
52
+ """Should handle flat bids/asks format."""
53
+ raw = {
54
+ "bids": [{"px": "100.5", "sz": "10"}],
55
+ "asks": [{"px": "101.0", "sz": "5"}],
56
+ }
57
+ result = normalize_l2_book(raw)
58
+
59
+ assert result["bids"] == [(100.5, 10.0)]
60
+ assert result["asks"] == [(101.0, 5.0)]
61
+
62
+ def test_calculates_mid_from_bids_asks(self):
63
+ """Should calculate mid price from best bid/ask when not provided."""
64
+ raw = {
65
+ "levels": [
66
+ [{"px": "100.0", "sz": "10"}],
67
+ [{"px": "102.0", "sz": "5"}],
68
+ ],
69
+ }
70
+ result = normalize_l2_book(raw)
71
+
72
+ assert result["midPx"] == 101.0 # (100 + 102) / 2
73
+
74
+ def test_fallback_mid(self):
75
+ """Should use fallback_mid when no mid can be calculated."""
76
+ raw = {"levels": [[], []]}
77
+ result = normalize_l2_book(raw, fallback_mid=99.0)
78
+
79
+ assert result["midPx"] == 99.0
80
+
81
+ def test_invalid_levels_skipped(self):
82
+ """Invalid level entries should be skipped."""
83
+ raw = {
84
+ "levels": [
85
+ [
86
+ {"px": "100.0", "sz": "10"}, # valid
87
+ {"px": "invalid", "sz": "5"}, # invalid - non-numeric
88
+ {"px": "0", "sz": "5"}, # invalid - zero price
89
+ {"px": "50", "sz": "0"}, # invalid - zero size
90
+ ],
91
+ [{"px": "101.0", "sz": "5"}],
92
+ ],
93
+ }
94
+ result = normalize_l2_book(raw)
95
+
96
+ assert result["bids"] == [(100.0, 10.0)] # Only valid entry
97
+
98
+ def test_tuple_format_levels(self):
99
+ """Should handle tuple/list format levels."""
100
+ raw = {
101
+ "levels": [
102
+ [[100.0, 10.0]], # bids as tuples
103
+ [[101.0, 5.0]], # asks as tuples
104
+ ],
105
+ }
106
+ result = normalize_l2_book(raw)
107
+
108
+ assert result["bids"] == [(100.0, 10.0)]
109
+ assert result["asks"] == [(101.0, 5.0)]
110
+
111
+
112
+ class TestUsdDepthInBand:
113
+ """Tests for usd_depth_in_band function."""
114
+
115
+ def test_buy_side(self):
116
+ """Buy side should measure ask depth within band."""
117
+ book = {
118
+ "bids": [(99.0, 10.0), (98.0, 20.0)], # $990 + $1960 = $2950
119
+ "asks": [(101.0, 5.0), (102.0, 10.0)], # $505 + $1020 = $1525
120
+ "midPx": 100.0,
121
+ }
122
+ # 100 bps = 1% band, so hi = 101.0
123
+ depth, mid = usd_depth_in_band(book, band_bps=100, side="buy")
124
+
125
+ assert mid == 100.0
126
+ # Only asks <= 101.0 count: 101.0 * 5.0 = 505
127
+ assert depth == 505.0
128
+
129
+ def test_sell_side(self):
130
+ """Sell side should measure bid depth within band."""
131
+ book = {
132
+ "bids": [(99.0, 10.0), (98.0, 20.0)],
133
+ "asks": [(101.0, 5.0), (102.0, 10.0)],
134
+ "midPx": 100.0,
135
+ }
136
+ # 100 bps = 1% band, so lo = 99.0
137
+ depth, mid = usd_depth_in_band(book, band_bps=100, side="sell")
138
+
139
+ assert mid == 100.0
140
+ # Only bids >= 99.0 count: 99.0 * 10.0 = 990
141
+ assert depth == 990.0
142
+
143
+ def test_zero_mid(self):
144
+ """Zero mid price should return zero depth."""
145
+ book = {"bids": [(99.0, 10.0)], "asks": [(101.0, 5.0)], "midPx": 0.0}
146
+ depth, mid = usd_depth_in_band(book, band_bps=100, side="buy")
147
+
148
+ assert depth == 0.0
149
+ assert mid == 0.0
150
+
151
+
152
+ class TestSzDecimalsForAsset:
153
+ """Tests for sz_decimals_for_asset function."""
154
+
155
+ def test_known_asset(self):
156
+ """Should return decimals for known asset."""
157
+ asset_to_sz = {0: 4, 1: 3, 10000: 6}
158
+
159
+ assert sz_decimals_for_asset(asset_to_sz, 0) == 4
160
+ assert sz_decimals_for_asset(asset_to_sz, 1) == 3
161
+ assert sz_decimals_for_asset(asset_to_sz, 10000) == 6
162
+
163
+ def test_unknown_raises(self):
164
+ """Unknown asset should raise ValueError."""
165
+ asset_to_sz = {0: 4}
166
+
167
+ with pytest.raises(ValueError, match="Unknown asset_id 999"):
168
+ sz_decimals_for_asset(asset_to_sz, 999)
169
+
170
+
171
+ class TestSizeStep:
172
+ """Tests for size_step function."""
173
+
174
+ def test_size_step_calculation(self):
175
+ """Size step should be 10^(-decimals)."""
176
+ from decimal import Decimal
177
+
178
+ asset_to_sz = {0: 4, 1: 2}
179
+
180
+ assert size_step(asset_to_sz, 0) == Decimal("0.0001")
181
+ assert size_step(asset_to_sz, 1) == Decimal("0.01")
182
+
183
+
184
+ class TestRoundSizeForAsset:
185
+ """Tests for round_size_for_asset function."""
186
+
187
+ def test_floors_correctly(self):
188
+ """Should floor to size step."""
189
+ asset_to_sz = {0: 4} # Step = 0.0001
190
+
191
+ # 1.23456789 should floor to 1.2345
192
+ result = round_size_for_asset(asset_to_sz, 0, 1.23456789)
193
+ assert result == 1.2345
194
+
195
+ def test_zero_returns_zero(self):
196
+ """Zero or negative size returns 0."""
197
+ asset_to_sz = {0: 4}
198
+
199
+ assert round_size_for_asset(asset_to_sz, 0, 0.0) == 0.0
200
+ assert round_size_for_asset(asset_to_sz, 0, -1.0) == 0.0
201
+
202
+ def test_ensure_min_step(self):
203
+ """With ensure_min_step=True, tiny values become one step."""
204
+ asset_to_sz = {0: 4} # Step = 0.0001
205
+
206
+ # Without ensure_min_step: 0.00001 floors to 0
207
+ result = round_size_for_asset(asset_to_sz, 0, 0.00001, ensure_min_step=False)
208
+ assert result == 0.0
209
+
210
+ # With ensure_min_step: 0.00001 becomes 0.0001 (one step)
211
+ result = round_size_for_asset(asset_to_sz, 0, 0.00001, ensure_min_step=True)
212
+ assert result == 0.0001
213
+
214
+ def test_preserves_precision(self):
215
+ """Should not introduce float precision errors."""
216
+ asset_to_sz = {0: 2} # Step = 0.01
217
+
218
+ # 0.1 + 0.2 = 0.30000000000000004 in float
219
+ result = round_size_for_asset(asset_to_sz, 0, 0.1 + 0.2)
220
+ assert result == 0.30
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from decimal import ROUND_DOWN, Decimal
5
+ from typing import Any
6
+
7
+
8
+ def spot_index_from_asset_id(spot_asset_id: int) -> int:
9
+ """Convert Hyperliquid spot asset_id (>=10000) to spot index."""
10
+ if spot_asset_id < 10000:
11
+ raise ValueError(f"Expected spot asset_id >= 10000, got {spot_asset_id}")
12
+ return int(spot_asset_id) - 10000
13
+
14
+
15
+ def normalize_l2_book(
16
+ raw: dict[str, Any],
17
+ *,
18
+ fallback_mid: float | None = None,
19
+ ) -> dict[str, Any]:
20
+ """Normalize Hyperliquid L2 into bids/asks lists with floats."""
21
+
22
+ def coerce_levels(levels: Any) -> list[tuple[float, float]]:
23
+ normalized: list[tuple[float, float]] = []
24
+ if not isinstance(levels, list):
25
+ return normalized
26
+ for level in levels:
27
+ try:
28
+ if isinstance(level, dict):
29
+ px = float(level.get("px"))
30
+ sz = float(level.get("sz"))
31
+ elif isinstance(level, (list, tuple)) and len(level) >= 2:
32
+ px = float(level[0])
33
+ sz = float(level[1])
34
+ else:
35
+ continue
36
+ except (TypeError, ValueError):
37
+ continue
38
+ if px > 0 and sz > 0:
39
+ normalized.append((px, sz))
40
+ return normalized
41
+
42
+ bids: list[tuple[float, float]] = []
43
+ asks: list[tuple[float, float]] = []
44
+
45
+ levels = raw.get("levels")
46
+ if isinstance(levels, list) and levels:
47
+ bids = coerce_levels(levels[0])
48
+ if len(levels) > 1:
49
+ asks = coerce_levels(levels[1])
50
+ else:
51
+ bids = coerce_levels(raw.get("bids"))
52
+ asks = coerce_levels(raw.get("asks"))
53
+
54
+ mid_px = None
55
+ try:
56
+ mid_raw = raw.get("midPx")
57
+ if mid_raw is not None:
58
+ mid_px = float(mid_raw)
59
+ except (TypeError, ValueError):
60
+ mid_px = None
61
+
62
+ if (mid_px is None or mid_px <= 0) and bids and asks:
63
+ mid_px = (bids[0][0] + asks[0][0]) / 2.0
64
+ if (mid_px is None or mid_px <= 0) and fallback_mid:
65
+ mid_px = float(fallback_mid)
66
+
67
+ return {
68
+ "bids": bids,
69
+ "asks": asks,
70
+ "midPx": float(mid_px or 0.0),
71
+ }
72
+
73
+
74
+ def usd_depth_in_band(
75
+ book: dict[str, Any], band_bps: int, side: str
76
+ ) -> tuple[float, float]:
77
+ """Return USD depth inside a +/- band (bps) around mid price for a side."""
78
+ bids = book.get("bids") or []
79
+ asks = book.get("asks") or []
80
+ mid = float(book.get("midPx") or 0.0)
81
+
82
+ if mid <= 0.0:
83
+ return 0.0, mid
84
+
85
+ lo = mid * (1.0 - band_bps / 1e4)
86
+ hi = mid * (1.0 + band_bps / 1e4)
87
+
88
+ def usd_sum(levels: list[tuple[float, float]], predicate) -> float:
89
+ total = 0.0
90
+ for px, sz in levels:
91
+ if predicate(px):
92
+ total += float(px) * float(sz)
93
+ return total
94
+
95
+ bids_usd = usd_sum(bids, lambda px: px >= lo)
96
+ asks_usd = usd_sum(asks, lambda px: px <= hi)
97
+
98
+ depth_side = asks_usd if side.lower() == "buy" else bids_usd
99
+ return float(depth_side), mid
100
+
101
+
102
+ def sz_decimals_for_asset(
103
+ asset_to_sz_decimals: Mapping[int, int], asset_id: int
104
+ ) -> int:
105
+ """Return Hyperliquid size decimals (szDecimals) for an asset_id."""
106
+ if asset_id not in asset_to_sz_decimals:
107
+ raise ValueError(f"Unknown asset_id {asset_id}: missing szDecimals")
108
+ return int(asset_to_sz_decimals[asset_id])
109
+
110
+
111
+ def size_step(asset_to_sz_decimals: Mapping[int, int], asset_id: int) -> Decimal:
112
+ """Return the size increment step for an asset_id."""
113
+ return Decimal(10) ** (-sz_decimals_for_asset(asset_to_sz_decimals, asset_id))
114
+
115
+
116
+ def round_size_for_asset(
117
+ asset_to_sz_decimals: Mapping[int, int],
118
+ asset_id: int,
119
+ size: float | Decimal,
120
+ *,
121
+ ensure_min_step: bool = False,
122
+ ) -> float:
123
+ """Floor to size step using Decimal to avoid float issues."""
124
+ size_d = size if isinstance(size, Decimal) else Decimal(str(size))
125
+ if size_d <= 0:
126
+ return 0.0
127
+
128
+ step = size_step(asset_to_sz_decimals, asset_id)
129
+ q = (size_d / step).to_integral_value(rounding=ROUND_DOWN) * step
130
+ if ensure_min_step and q == 0:
131
+ q = step
132
+
133
+ decimals = sz_decimals_for_asset(asset_to_sz_decimals, asset_id)
134
+ return float(f"{q:.{decimals}f}")