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.
- wayfinder_paths/adapters/balance_adapter/README.md +0 -10
- wayfinder_paths/adapters/balance_adapter/adapter.py +0 -20
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -30
- wayfinder_paths/adapters/brap_adapter/adapter.py +3 -2
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +9 -13
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +14 -7
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
- wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +7 -6
- wayfinder_paths/adapters/pool_adapter/README.md +3 -28
- wayfinder_paths/adapters/pool_adapter/adapter.py +0 -72
- wayfinder_paths/adapters/pool_adapter/examples.json +0 -43
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +4 -54
- wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -14
- wayfinder_paths/core/adapters/models.py +9 -4
- wayfinder_paths/core/analytics/__init__.py +11 -0
- wayfinder_paths/core/analytics/bootstrap.py +57 -0
- wayfinder_paths/core/analytics/stats.py +48 -0
- wayfinder_paths/core/analytics/test_analytics.py +170 -0
- wayfinder_paths/core/clients/BRAPClient.py +1 -0
- wayfinder_paths/core/clients/LedgerClient.py +2 -7
- wayfinder_paths/core/clients/PoolClient.py +0 -16
- wayfinder_paths/core/clients/WalletClient.py +0 -27
- wayfinder_paths/core/clients/protocols.py +104 -18
- wayfinder_paths/scripts/make_wallets.py +9 -0
- wayfinder_paths/scripts/run_strategy.py +124 -0
- wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
- wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
- wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
- wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
- wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
- wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +1 -9
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +36 -5
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +367 -278
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +204 -7
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/METADATA +32 -3
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/RECORD +50 -27
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/WHEEL +0 -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}")
|