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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from unittest.mock import AsyncMock
|
|
1
|
+
from unittest.mock import AsyncMock
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
@@ -17,11 +17,9 @@ class TestLedgerAdapter:
|
|
|
17
17
|
@pytest.fixture
|
|
18
18
|
def adapter(self, mock_ledger_client):
|
|
19
19
|
"""Create a LedgerAdapter instance with mocked client for testing"""
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
):
|
|
24
|
-
return LedgerAdapter()
|
|
20
|
+
adapter = LedgerAdapter()
|
|
21
|
+
adapter.ledger_client = mock_ledger_client
|
|
22
|
+
return adapter
|
|
25
23
|
|
|
26
24
|
@pytest.mark.asyncio
|
|
27
25
|
async def test_get_strategy_transactions_success(self, adapter, mock_ledger_client):
|
|
@@ -161,12 +159,15 @@ class TestLedgerAdapter:
|
|
|
161
159
|
|
|
162
160
|
# Test
|
|
163
161
|
operation_data = SWAP(
|
|
162
|
+
adapter="TestAdapter",
|
|
164
163
|
from_token_id="0xA0b86a33E6441c8C06DdD4D4c4c4c4c4c4c4c4c4c",
|
|
165
164
|
to_token_id="0xB1c97a44F7552d9Dd5e5e5e5e5e5e5e5e5e5e5e5e5e",
|
|
166
165
|
from_amount="1000000000000000000",
|
|
167
166
|
to_amount="995000000000000000",
|
|
168
167
|
from_amount_usd=1000.0,
|
|
169
168
|
to_amount_usd=995.0,
|
|
169
|
+
transaction_hash="0x123abc",
|
|
170
|
+
transaction_chain_id=8453,
|
|
170
171
|
)
|
|
171
172
|
|
|
172
173
|
success, data = await adapter.record_operation(
|
|
@@ -15,6 +15,7 @@ A Wayfinder adapter that provides high-level operations for DeFi pool data and a
|
|
|
15
15
|
The adapter uses the PoolClient which automatically handles authentication and API configuration through the Wayfinder settings. No additional configuration is required.
|
|
16
16
|
|
|
17
17
|
The PoolClient will automatically:
|
|
18
|
+
|
|
18
19
|
- Use the WAYFINDER_API_URL from settings
|
|
19
20
|
- Handle authentication via environment variables or config.json
|
|
20
21
|
- Manage token refresh and retry logic
|
|
@@ -44,17 +45,6 @@ else:
|
|
|
44
45
|
print(f"Error: {data}")
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
### Get All Pools
|
|
48
|
-
|
|
49
|
-
```python
|
|
50
|
-
success, data = await adapter.get_all_pools(merge_external=False)
|
|
51
|
-
if success:
|
|
52
|
-
pools = data.get("pools", [])
|
|
53
|
-
print(f"Total pools available: {len(pools)}")
|
|
54
|
-
else:
|
|
55
|
-
print(f"Error: {data}")
|
|
56
|
-
```
|
|
57
|
-
|
|
58
48
|
### Find High Yield Pools
|
|
59
49
|
|
|
60
50
|
```python
|
|
@@ -90,22 +80,6 @@ else:
|
|
|
90
80
|
print(f"Error: {data}")
|
|
91
81
|
```
|
|
92
82
|
|
|
93
|
-
### Search Pools
|
|
94
|
-
|
|
95
|
-
```python
|
|
96
|
-
success, data = await adapter.search_pools(
|
|
97
|
-
query="USDC",
|
|
98
|
-
limit=10
|
|
99
|
-
)
|
|
100
|
-
if success:
|
|
101
|
-
pools = data.get("pools", [])
|
|
102
|
-
print(f"Found {len(pools)} pools matching 'USDC'")
|
|
103
|
-
for pool in pools:
|
|
104
|
-
print(f"Pool: {pool.get('name')} - {pool.get('symbol')}")
|
|
105
|
-
else:
|
|
106
|
-
print(f"Error: {data}")
|
|
107
|
-
```
|
|
108
|
-
|
|
109
83
|
### Get Llama Matches
|
|
110
84
|
|
|
111
85
|
```python
|
|
@@ -168,7 +142,7 @@ if success:
|
|
|
168
142
|
for pool_analytics in analytics:
|
|
169
143
|
pool = pool_analytics.get("pool", {})
|
|
170
144
|
llama_data = pool_analytics.get("llama_data", {})
|
|
171
|
-
|
|
145
|
+
|
|
172
146
|
print(f"Pool: {pool.get('name')}")
|
|
173
147
|
print(f" Combined APY: {pool_analytics.get('combined_apy', 0):.2%}")
|
|
174
148
|
print(f" TVL: ${pool_analytics.get('tvl_usd', 0):,.0f}")
|
|
@@ -189,6 +163,7 @@ The adapter uses the following Wayfinder API endpoints:
|
|
|
189
163
|
## Error Handling
|
|
190
164
|
|
|
191
165
|
All methods return a tuple of `(success: bool, data: Any)` where:
|
|
166
|
+
|
|
192
167
|
- `success` is `True` if the operation succeeded
|
|
193
168
|
- `data` contains the response data on success or error message on failure
|
|
194
169
|
|
|
@@ -53,25 +53,6 @@ class PoolAdapter(BaseAdapter):
|
|
|
53
53
|
self.logger.error(f"Error fetching pools by IDs: {e}")
|
|
54
54
|
return (False, str(e))
|
|
55
55
|
|
|
56
|
-
async def get_all_pools(
|
|
57
|
-
self, merge_external: bool | None = None
|
|
58
|
-
) -> tuple[bool, PoolList | str]:
|
|
59
|
-
"""
|
|
60
|
-
Get all available pools.
|
|
61
|
-
|
|
62
|
-
Args:
|
|
63
|
-
merge_external: Whether to merge external data
|
|
64
|
-
|
|
65
|
-
Returns:
|
|
66
|
-
Tuple of (success, data) where data is all pools or error message
|
|
67
|
-
"""
|
|
68
|
-
try:
|
|
69
|
-
data = await self.pool_client.get_all_pools(merge_external=merge_external)
|
|
70
|
-
return (True, data)
|
|
71
|
-
except Exception as e:
|
|
72
|
-
self.logger.error(f"Error fetching all pools: {e}")
|
|
73
|
-
return (False, str(e))
|
|
74
|
-
|
|
75
56
|
async def get_llama_matches(self) -> tuple[bool, dict[str, LlamaMatch] | str]:
|
|
76
57
|
"""
|
|
77
58
|
Get Llama protocol matches for pools.
|
|
@@ -227,56 +208,3 @@ class PoolAdapter(BaseAdapter):
|
|
|
227
208
|
except Exception as e:
|
|
228
209
|
self.logger.error(f"Error getting pool analytics: {e}")
|
|
229
210
|
return (False, str(e))
|
|
230
|
-
|
|
231
|
-
async def search_pools(self, query: str, limit: int = 10) -> tuple[bool, Any]:
|
|
232
|
-
"""
|
|
233
|
-
Search pools by name, symbol, or other criteria.
|
|
234
|
-
|
|
235
|
-
Args:
|
|
236
|
-
query: Search query string
|
|
237
|
-
limit: Maximum number of results
|
|
238
|
-
|
|
239
|
-
Returns:
|
|
240
|
-
Tuple of (success, data) where data is search results or error message
|
|
241
|
-
"""
|
|
242
|
-
try:
|
|
243
|
-
success, all_pools_data = await self.get_all_pools()
|
|
244
|
-
if not success:
|
|
245
|
-
return (False, f"Failed to fetch pools: {all_pools_data}")
|
|
246
|
-
|
|
247
|
-
pools = all_pools_data.get("pools", [])
|
|
248
|
-
query_lower = query.lower()
|
|
249
|
-
|
|
250
|
-
# Simple text search
|
|
251
|
-
matching_pools = []
|
|
252
|
-
for pool in pools:
|
|
253
|
-
name = pool.get("name", "").lower()
|
|
254
|
-
symbol = pool.get("symbol", "").lower()
|
|
255
|
-
description = pool.get("description", "").lower()
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
query_lower in name
|
|
259
|
-
or query_lower in symbol
|
|
260
|
-
or query_lower in description
|
|
261
|
-
):
|
|
262
|
-
matching_pools.append(pool)
|
|
263
|
-
|
|
264
|
-
# Sort by relevance (exact matches first)
|
|
265
|
-
matching_pools.sort(
|
|
266
|
-
key=lambda x: (
|
|
267
|
-
query_lower not in x.get("name", "").lower(),
|
|
268
|
-
query_lower not in x.get("symbol", "").lower(),
|
|
269
|
-
)
|
|
270
|
-
)
|
|
271
|
-
|
|
272
|
-
return (
|
|
273
|
-
True,
|
|
274
|
-
{
|
|
275
|
-
"pools": matching_pools[:limit],
|
|
276
|
-
"total_found": len(matching_pools),
|
|
277
|
-
"query": query,
|
|
278
|
-
},
|
|
279
|
-
)
|
|
280
|
-
except Exception as e:
|
|
281
|
-
self.logger.error(f"Error searching pools: {e}")
|
|
282
|
-
return (False, str(e))
|
|
@@ -21,27 +21,6 @@
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
|
-
"get_all_pools": {
|
|
25
|
-
"description": "Get all available pools",
|
|
26
|
-
"input": {
|
|
27
|
-
"merge_external": false
|
|
28
|
-
},
|
|
29
|
-
"output": {
|
|
30
|
-
"success": true,
|
|
31
|
-
"data": {
|
|
32
|
-
"pools": [
|
|
33
|
-
{
|
|
34
|
-
"id": "pool-123",
|
|
35
|
-
"name": "USDC/USDT Pool",
|
|
36
|
-
"symbol": "USDC-USDT",
|
|
37
|
-
"apy": 0.05,
|
|
38
|
-
"tvl": 1000000
|
|
39
|
-
}
|
|
40
|
-
],
|
|
41
|
-
"total": 1
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
24
|
"get_llama_matches": {
|
|
46
25
|
"description": "Get Llama protocol matches for pools",
|
|
47
26
|
"input": {},
|
|
@@ -117,27 +96,5 @@
|
|
|
117
96
|
"total_pools": 1
|
|
118
97
|
}
|
|
119
98
|
}
|
|
120
|
-
},
|
|
121
|
-
"search_pools": {
|
|
122
|
-
"description": "Search pools by name, symbol, or other criteria",
|
|
123
|
-
"input": {
|
|
124
|
-
"query": "USDC",
|
|
125
|
-
"limit": 5
|
|
126
|
-
},
|
|
127
|
-
"output": {
|
|
128
|
-
"success": true,
|
|
129
|
-
"data": {
|
|
130
|
-
"pools": [
|
|
131
|
-
{
|
|
132
|
-
"id": "pool-123",
|
|
133
|
-
"name": "USDC/USDT Pool",
|
|
134
|
-
"symbol": "USDC-USDT",
|
|
135
|
-
"description": "Stablecoin pool on Base"
|
|
136
|
-
}
|
|
137
|
-
],
|
|
138
|
-
"total_found": 1,
|
|
139
|
-
"query": "USDC"
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
99
|
}
|
|
143
100
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from unittest.mock import AsyncMock
|
|
1
|
+
from unittest.mock import AsyncMock
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
@@ -17,11 +17,9 @@ class TestPoolAdapter:
|
|
|
17
17
|
@pytest.fixture
|
|
18
18
|
def adapter(self, mock_pool_client):
|
|
19
19
|
"""Create a PoolAdapter instance with mocked client for testing"""
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
):
|
|
24
|
-
return PoolAdapter()
|
|
20
|
+
adapter = PoolAdapter()
|
|
21
|
+
adapter.pool_client = mock_pool_client
|
|
22
|
+
return adapter
|
|
25
23
|
|
|
26
24
|
@pytest.mark.asyncio
|
|
27
25
|
async def test_get_pools_by_ids_success(self, adapter, mock_pool_client):
|
|
@@ -49,25 +47,6 @@ class TestPoolAdapter:
|
|
|
49
47
|
pool_ids="pool-123,pool-456", merge_external=True
|
|
50
48
|
)
|
|
51
49
|
|
|
52
|
-
@pytest.mark.asyncio
|
|
53
|
-
async def test_get_all_pools_success(self, adapter, mock_pool_client):
|
|
54
|
-
"""Test successful retrieval of all pools"""
|
|
55
|
-
# Mock response
|
|
56
|
-
mock_response = {
|
|
57
|
-
"pools": [
|
|
58
|
-
{"id": "pool-123", "name": "Pool 1"},
|
|
59
|
-
{"id": "pool-456", "name": "Pool 2"},
|
|
60
|
-
],
|
|
61
|
-
"total": 2,
|
|
62
|
-
}
|
|
63
|
-
mock_pool_client.get_all_pools = AsyncMock(return_value=mock_response)
|
|
64
|
-
|
|
65
|
-
success, data = await adapter.get_all_pools(merge_external=False)
|
|
66
|
-
|
|
67
|
-
assert success is True
|
|
68
|
-
assert data == mock_response
|
|
69
|
-
mock_pool_client.get_all_pools.assert_called_once_with(merge_external=False)
|
|
70
|
-
|
|
71
50
|
@pytest.mark.asyncio
|
|
72
51
|
async def test_get_llama_matches_success(self, adapter, mock_pool_client):
|
|
73
52
|
"""Test successful Llama matches retrieval"""
|
|
@@ -161,35 +140,6 @@ class TestPoolAdapter:
|
|
|
161
140
|
assert round(data["analytics"][0]["combined_apy"], 6) == round(0.052, 6)
|
|
162
141
|
assert data["analytics"][0]["tvl_usd"] == 1000000
|
|
163
142
|
|
|
164
|
-
@pytest.mark.asyncio
|
|
165
|
-
async def test_search_pools_success(self, adapter, mock_pool_client):
|
|
166
|
-
"""Test successful pool search"""
|
|
167
|
-
mock_all_pools = {
|
|
168
|
-
"pools": [
|
|
169
|
-
{
|
|
170
|
-
"id": "pool-123",
|
|
171
|
-
"name": "USDC/USDT Pool",
|
|
172
|
-
"symbol": "USDC-USDT",
|
|
173
|
-
"description": "Stablecoin pool on Base",
|
|
174
|
-
},
|
|
175
|
-
{
|
|
176
|
-
"id": "pool-456",
|
|
177
|
-
"name": "ETH/WETH Pool",
|
|
178
|
-
"symbol": "ETH-WETH",
|
|
179
|
-
"description": "Ethereum pool",
|
|
180
|
-
},
|
|
181
|
-
]
|
|
182
|
-
}
|
|
183
|
-
mock_pool_client.get_all_pools = AsyncMock(return_value=mock_all_pools)
|
|
184
|
-
|
|
185
|
-
success, data = await adapter.search_pools("USDC", limit=5)
|
|
186
|
-
|
|
187
|
-
assert success is True
|
|
188
|
-
assert len(data["pools"]) == 1
|
|
189
|
-
assert data["pools"][0]["id"] == "pool-123"
|
|
190
|
-
assert data["total_found"] == 1
|
|
191
|
-
assert data["query"] == "USDC"
|
|
192
|
-
|
|
193
143
|
@pytest.mark.asyncio
|
|
194
144
|
async def test_get_pools_by_ids_failure(self, adapter, mock_pool_client):
|
|
195
145
|
"""Test pool retrieval failure"""
|
|
@@ -43,31 +43,21 @@ class TestTokenAdapter:
|
|
|
43
43
|
success, data = await adapter.get_token("0x1234...")
|
|
44
44
|
|
|
45
45
|
assert success is False
|
|
46
|
-
assert "No token found for
|
|
46
|
+
assert "No token found for" in data
|
|
47
47
|
|
|
48
48
|
@pytest.mark.asyncio
|
|
49
|
-
async def
|
|
50
|
-
"""Test
|
|
49
|
+
async def test_get_token_by_token_id(self, adapter):
|
|
50
|
+
"""Test token retrieval with token_id"""
|
|
51
51
|
mock_token_data = {"address": "0x1234...", "symbol": "TEST"}
|
|
52
52
|
|
|
53
53
|
with patch.object(
|
|
54
54
|
adapter.token_client, "get_token_details", return_value=mock_token_data
|
|
55
55
|
):
|
|
56
|
-
success, data = await adapter.get_token(
|
|
57
|
-
address="0x1234...", token_id="token-123"
|
|
58
|
-
)
|
|
56
|
+
success, data = await adapter.get_token("token-123")
|
|
59
57
|
|
|
60
58
|
assert success is True
|
|
61
59
|
assert data == mock_token_data
|
|
62
60
|
|
|
63
|
-
@pytest.mark.asyncio
|
|
64
|
-
async def test_get_token_no_parameters(self, adapter):
|
|
65
|
-
"""Test get_token with no parameters raises error"""
|
|
66
|
-
success, data = await adapter.get_token()
|
|
67
|
-
|
|
68
|
-
assert success is False
|
|
69
|
-
assert "Either address or token_id must be provided" in data
|
|
70
|
-
|
|
71
61
|
def test_adapter_type(self, adapter):
|
|
72
62
|
"""Test adapter has adapter_type"""
|
|
73
63
|
assert adapter.adapter_type == "TOKEN"
|
|
@@ -5,7 +5,13 @@ from typing import Annotated, Any, Literal
|
|
|
5
5
|
from pydantic import BaseModel, Field
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
class
|
|
8
|
+
class OperationBase(BaseModel):
|
|
9
|
+
adapter: str
|
|
10
|
+
transaction_hash: str | None
|
|
11
|
+
transaction_chain_id: int | None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SWAP(OperationBase):
|
|
9
15
|
"""Swap operation."""
|
|
10
16
|
|
|
11
17
|
type: Literal["SWAP"] = "SWAP"
|
|
@@ -15,18 +21,17 @@ class SWAP(BaseModel):
|
|
|
15
21
|
to_amount: str
|
|
16
22
|
from_amount_usd: float
|
|
17
23
|
to_amount_usd: float
|
|
18
|
-
transaction_hash: str | None = None
|
|
19
24
|
transaction_status: str | None = None
|
|
20
25
|
transaction_receipt: dict[str, Any] | None = None
|
|
21
26
|
|
|
22
27
|
|
|
23
|
-
class LEND(
|
|
28
|
+
class LEND(OperationBase):
|
|
24
29
|
type: Literal["LEND"] = "LEND"
|
|
25
30
|
contract: str
|
|
26
31
|
amount: int
|
|
27
32
|
|
|
28
33
|
|
|
29
|
-
class UNLEND(
|
|
34
|
+
class UNLEND(OperationBase):
|
|
30
35
|
type: Literal["UNLEND"] = "UNLEND"
|
|
31
36
|
contract: str
|
|
32
37
|
amount: int
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Reusable, strategy-agnostic analytics helpers."""
|
|
2
|
+
|
|
3
|
+
from .bootstrap import block_bootstrap_paths
|
|
4
|
+
from .stats import percentile, rolling_min_sum, z_from_conf
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"block_bootstrap_paths",
|
|
8
|
+
"percentile",
|
|
9
|
+
"rolling_min_sum",
|
|
10
|
+
"z_from_conf",
|
|
11
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def block_bootstrap_paths(
|
|
8
|
+
*series: Sequence[float],
|
|
9
|
+
block_hours: int,
|
|
10
|
+
sims: int,
|
|
11
|
+
rng: random.Random,
|
|
12
|
+
) -> list[tuple[list[float], ...]]:
|
|
13
|
+
"""
|
|
14
|
+
Block-bootstrap aligned series together.
|
|
15
|
+
|
|
16
|
+
Samples contiguous blocks from the same indices across every series so their
|
|
17
|
+
time alignment is preserved (useful for resampling correlated paths).
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
*series: One or more equal-frequency series (funding, closes, highs, ...)
|
|
21
|
+
block_hours: Block length for sampling (clamped to [1, base_len])
|
|
22
|
+
sims: Number of bootstrap paths to return
|
|
23
|
+
rng: Random generator
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
List of tuples, each containing resampled series lists (truncated to the
|
|
27
|
+
shared base length).
|
|
28
|
+
"""
|
|
29
|
+
if sims <= 0 or not series:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
base_len = min(len(s) for s in series)
|
|
33
|
+
if base_len <= 1:
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
block_hours = max(1, min(int(block_hours), base_len))
|
|
37
|
+
max_start = max(0, base_len - block_hours)
|
|
38
|
+
|
|
39
|
+
if max_start == 0:
|
|
40
|
+
# Series are shorter than a full block; just return copies.
|
|
41
|
+
out: list[tuple[list[float], ...]] = []
|
|
42
|
+
for _ in range(sims):
|
|
43
|
+
out.append(tuple(list(s[:base_len]) for s in series))
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
bootstrap_paths: list[tuple[list[float], ...]] = []
|
|
47
|
+
for _ in range(sims):
|
|
48
|
+
sampled: list[list[float]] = [[] for _ in series]
|
|
49
|
+
while len(sampled[0]) < base_len:
|
|
50
|
+
start = rng.randint(0, max_start)
|
|
51
|
+
end = start + block_hours
|
|
52
|
+
for i, s in enumerate(series):
|
|
53
|
+
sampled[i].extend(s[start:end])
|
|
54
|
+
|
|
55
|
+
bootstrap_paths.append(tuple(x[:base_len] for x in sampled))
|
|
56
|
+
|
|
57
|
+
return bootstrap_paths
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from statistics import NormalDist
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def z_from_conf(confidence: float) -> float:
|
|
9
|
+
"""Return the two-sided z-score for a confidence level (e.g. 0.975)."""
|
|
10
|
+
return NormalDist().inv_cdf((1 + float(confidence)) / 2)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def rolling_min_sum(arr: Sequence[float], window: int) -> float:
|
|
14
|
+
"""Return the minimum rolling window sum over `arr`."""
|
|
15
|
+
values = list(arr)
|
|
16
|
+
if window <= 0:
|
|
17
|
+
return 0.0
|
|
18
|
+
if len(values) < window:
|
|
19
|
+
return float(sum(values))
|
|
20
|
+
|
|
21
|
+
current_sum = sum(values[:window])
|
|
22
|
+
min_sum = current_sum
|
|
23
|
+
for i in range(window, len(values)):
|
|
24
|
+
current_sum = current_sum - values[i - window] + values[i]
|
|
25
|
+
min_sum = min(min_sum, current_sum)
|
|
26
|
+
return float(min_sum)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def percentile(sorted_values: Sequence[float], pct: float) -> float:
|
|
30
|
+
"""
|
|
31
|
+
Inclusive percentile on a pre-sorted list.
|
|
32
|
+
|
|
33
|
+
Mirrors a simple linear interpolation between closest ranks.
|
|
34
|
+
"""
|
|
35
|
+
values = list(sorted_values)
|
|
36
|
+
if not values:
|
|
37
|
+
return float("nan")
|
|
38
|
+
if len(values) == 1:
|
|
39
|
+
return float(values[0])
|
|
40
|
+
|
|
41
|
+
pct = min(max(float(pct), 0.0), 1.0)
|
|
42
|
+
idx = (len(values) - 1) * pct
|
|
43
|
+
lower = math.floor(idx)
|
|
44
|
+
upper = math.ceil(idx)
|
|
45
|
+
if lower == upper:
|
|
46
|
+
return float(values[lower])
|
|
47
|
+
weight = idx - lower
|
|
48
|
+
return float(values[lower] + weight * (values[upper] - values[lower]))
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Tests for core analytics modules (bootstrap, stats)."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import random
|
|
5
|
+
|
|
6
|
+
from wayfinder_paths.core.analytics.bootstrap import block_bootstrap_paths
|
|
7
|
+
from wayfinder_paths.core.analytics.stats import (
|
|
8
|
+
percentile,
|
|
9
|
+
rolling_min_sum,
|
|
10
|
+
z_from_conf,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestBlockBootstrapPaths:
|
|
15
|
+
"""Tests for block_bootstrap_paths function."""
|
|
16
|
+
|
|
17
|
+
def test_returns_empty_when_sims_zero(self):
|
|
18
|
+
"""Should return empty list when sims=0."""
|
|
19
|
+
result = block_bootstrap_paths(
|
|
20
|
+
[1.0, 2.0, 3.0], block_hours=2, sims=0, rng=random.Random(42)
|
|
21
|
+
)
|
|
22
|
+
assert result == []
|
|
23
|
+
|
|
24
|
+
def test_returns_empty_when_series_empty(self):
|
|
25
|
+
"""Should return empty list when no series provided."""
|
|
26
|
+
result = block_bootstrap_paths(block_hours=2, sims=10, rng=random.Random(42))
|
|
27
|
+
assert result == []
|
|
28
|
+
|
|
29
|
+
def test_returns_empty_when_base_len_one(self):
|
|
30
|
+
"""Should return empty list when series has only one element."""
|
|
31
|
+
result = block_bootstrap_paths(
|
|
32
|
+
[1.0], block_hours=2, sims=10, rng=random.Random(42)
|
|
33
|
+
)
|
|
34
|
+
assert result == []
|
|
35
|
+
|
|
36
|
+
def test_single_series(self):
|
|
37
|
+
"""Should bootstrap a single series correctly."""
|
|
38
|
+
series = [1.0, 2.0, 3.0, 4.0, 5.0]
|
|
39
|
+
result = block_bootstrap_paths(
|
|
40
|
+
series, block_hours=2, sims=5, rng=random.Random(42)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert len(result) == 5
|
|
44
|
+
for path in result:
|
|
45
|
+
assert len(path) == 1 # One series
|
|
46
|
+
assert len(path[0]) == 5 # Same length as original
|
|
47
|
+
|
|
48
|
+
def test_multiple_aligned_series(self):
|
|
49
|
+
"""Should bootstrap multiple series with aligned indices."""
|
|
50
|
+
series_a = [1.0, 2.0, 3.0, 4.0, 5.0]
|
|
51
|
+
series_b = [10.0, 20.0, 30.0, 40.0, 50.0]
|
|
52
|
+
result = block_bootstrap_paths(
|
|
53
|
+
series_a, series_b, block_hours=2, sims=3, rng=random.Random(42)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
assert len(result) == 3
|
|
57
|
+
for path in result:
|
|
58
|
+
assert len(path) == 2 # Two series
|
|
59
|
+
assert len(path[0]) == 5
|
|
60
|
+
assert len(path[1]) == 5
|
|
61
|
+
|
|
62
|
+
def test_preserves_length(self):
|
|
63
|
+
"""Bootstrapped paths should have same length as input."""
|
|
64
|
+
series = list(range(100))
|
|
65
|
+
series_float = [float(x) for x in series]
|
|
66
|
+
result = block_bootstrap_paths(
|
|
67
|
+
series_float, block_hours=24, sims=10, rng=random.Random(42)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
for path in result:
|
|
71
|
+
assert len(path[0]) == 100
|
|
72
|
+
|
|
73
|
+
def test_block_clamping(self):
|
|
74
|
+
"""Block hours should be clamped to valid range."""
|
|
75
|
+
series = [1.0, 2.0, 3.0]
|
|
76
|
+
|
|
77
|
+
# Block larger than series - should still work
|
|
78
|
+
result = block_bootstrap_paths(
|
|
79
|
+
series, block_hours=100, sims=2, rng=random.Random(42)
|
|
80
|
+
)
|
|
81
|
+
assert len(result) == 2
|
|
82
|
+
|
|
83
|
+
# Block of zero - should clamp to 1
|
|
84
|
+
result = block_bootstrap_paths(
|
|
85
|
+
series, block_hours=0, sims=2, rng=random.Random(42)
|
|
86
|
+
)
|
|
87
|
+
assert len(result) == 2
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestZFromConf:
|
|
91
|
+
"""Tests for z_from_conf function."""
|
|
92
|
+
|
|
93
|
+
def test_95_confidence(self):
|
|
94
|
+
"""95% confidence should give z ≈ 1.96."""
|
|
95
|
+
z = z_from_conf(0.95)
|
|
96
|
+
assert 1.95 < z < 1.97
|
|
97
|
+
|
|
98
|
+
def test_99_confidence(self):
|
|
99
|
+
"""99% confidence should give z ≈ 2.576."""
|
|
100
|
+
z = z_from_conf(0.99)
|
|
101
|
+
assert 2.57 < z < 2.58
|
|
102
|
+
|
|
103
|
+
def test_90_confidence(self):
|
|
104
|
+
"""90% confidence should give z ≈ 1.645."""
|
|
105
|
+
z = z_from_conf(0.90)
|
|
106
|
+
assert 1.64 < z < 1.66
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestRollingMinSum:
|
|
110
|
+
"""Tests for rolling_min_sum function."""
|
|
111
|
+
|
|
112
|
+
def test_basic(self):
|
|
113
|
+
"""Basic rolling min sum calculation."""
|
|
114
|
+
arr = [1, -2, 3, -4, 5]
|
|
115
|
+
result = rolling_min_sum(arr, 2)
|
|
116
|
+
# Windows: [1,-2]=-1, [-2,3]=1, [3,-4]=-1, [-4,5]=1
|
|
117
|
+
assert result == -1
|
|
118
|
+
|
|
119
|
+
def test_window_larger_than_arr(self):
|
|
120
|
+
"""Window larger than array returns sum of array."""
|
|
121
|
+
arr = [1.0, 2.0, 3.0]
|
|
122
|
+
result = rolling_min_sum(arr, 10)
|
|
123
|
+
assert result == 6.0
|
|
124
|
+
|
|
125
|
+
def test_window_zero(self):
|
|
126
|
+
"""Window of zero returns 0."""
|
|
127
|
+
arr = [1.0, 2.0, 3.0]
|
|
128
|
+
result = rolling_min_sum(arr, 0)
|
|
129
|
+
assert result == 0.0
|
|
130
|
+
|
|
131
|
+
def test_all_negative(self):
|
|
132
|
+
"""All negative values."""
|
|
133
|
+
arr = [-1.0, -2.0, -3.0, -4.0]
|
|
134
|
+
result = rolling_min_sum(arr, 2)
|
|
135
|
+
# Windows: [-1,-2]=-3, [-2,-3]=-5, [-3,-4]=-7
|
|
136
|
+
assert result == -7.0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestPercentile:
|
|
140
|
+
"""Tests for percentile function."""
|
|
141
|
+
|
|
142
|
+
def test_empty_returns_nan(self):
|
|
143
|
+
"""Empty list returns nan."""
|
|
144
|
+
result = percentile([], 0.5)
|
|
145
|
+
assert math.isnan(result)
|
|
146
|
+
|
|
147
|
+
def test_single_value(self):
|
|
148
|
+
"""Single value returns that value regardless of percentile."""
|
|
149
|
+
assert percentile([42.0], 0.0) == 42.0
|
|
150
|
+
assert percentile([42.0], 0.5) == 42.0
|
|
151
|
+
assert percentile([42.0], 1.0) == 42.0
|
|
152
|
+
|
|
153
|
+
def test_median(self):
|
|
154
|
+
"""50th percentile (median) calculation."""
|
|
155
|
+
sorted_values = [1.0, 2.0, 3.0, 4.0, 5.0]
|
|
156
|
+
result = percentile(sorted_values, 0.5)
|
|
157
|
+
assert result == 3.0
|
|
158
|
+
|
|
159
|
+
def test_interpolation(self):
|
|
160
|
+
"""Percentile with interpolation."""
|
|
161
|
+
sorted_values = [0.0, 10.0]
|
|
162
|
+
# 25th percentile should interpolate to 2.5
|
|
163
|
+
result = percentile(sorted_values, 0.25)
|
|
164
|
+
assert result == 2.5
|
|
165
|
+
|
|
166
|
+
def test_bounds_clamped(self):
|
|
167
|
+
"""Percentile values outside [0,1] are clamped."""
|
|
168
|
+
sorted_values = [1.0, 2.0, 3.0]
|
|
169
|
+
assert percentile(sorted_values, -1.0) == 1.0 # Clamped to 0
|
|
170
|
+
assert percentile(sorted_values, 2.0) == 3.0 # Clamped to 1
|
|
@@ -27,6 +27,7 @@ class BRAPQuote(TypedDict):
|
|
|
27
27
|
amount1: Required[str]
|
|
28
28
|
amount2: NotRequired[str]
|
|
29
29
|
routes: NotRequired[list[dict[str, Any]]]
|
|
30
|
+
best_route: NotRequired[dict[str, Any]]
|
|
30
31
|
fees: NotRequired[dict[str, Any] | None]
|
|
31
32
|
slippage: NotRequired[float | None]
|
|
32
33
|
wayfinder_fee: NotRequired[float | None]
|