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,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
|