wayfinder-paths 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of wayfinder-paths might be problematic. Click here for more details.

Files changed (50) hide show
  1. wayfinder_paths/adapters/balance_adapter/README.md +0 -10
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +0 -20
  3. wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -30
  4. wayfinder_paths/adapters/brap_adapter/adapter.py +3 -2
  5. wayfinder_paths/adapters/brap_adapter/test_adapter.py +9 -13
  6. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +14 -7
  7. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
  8. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
  9. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
  10. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
  11. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
  12. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
  13. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
  14. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
  15. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
  16. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +7 -6
  17. wayfinder_paths/adapters/pool_adapter/README.md +3 -28
  18. wayfinder_paths/adapters/pool_adapter/adapter.py +0 -72
  19. wayfinder_paths/adapters/pool_adapter/examples.json +0 -43
  20. wayfinder_paths/adapters/pool_adapter/test_adapter.py +4 -54
  21. wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -14
  22. wayfinder_paths/core/adapters/models.py +9 -4
  23. wayfinder_paths/core/analytics/__init__.py +11 -0
  24. wayfinder_paths/core/analytics/bootstrap.py +57 -0
  25. wayfinder_paths/core/analytics/stats.py +48 -0
  26. wayfinder_paths/core/analytics/test_analytics.py +170 -0
  27. wayfinder_paths/core/clients/BRAPClient.py +1 -0
  28. wayfinder_paths/core/clients/LedgerClient.py +2 -7
  29. wayfinder_paths/core/clients/PoolClient.py +0 -16
  30. wayfinder_paths/core/clients/WalletClient.py +0 -27
  31. wayfinder_paths/core/clients/protocols.py +104 -18
  32. wayfinder_paths/scripts/make_wallets.py +9 -0
  33. wayfinder_paths/scripts/run_strategy.py +124 -0
  34. wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
  35. wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
  36. wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
  37. wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
  38. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
  39. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
  40. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
  41. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
  42. wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
  43. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +1 -9
  44. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +36 -5
  45. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +367 -278
  46. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +204 -7
  47. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/METADATA +32 -3
  48. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/RECORD +50 -27
  49. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/LICENSE +0 -0
  50. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/WHEEL +0 -0
@@ -1,4 +1,4 @@
1
- from unittest.mock import AsyncMock, patch
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
- with patch(
21
- "adapters.ledger_adapter.adapter.LedgerClient",
22
- return_value=mock_ledger_client,
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, patch
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
- with patch(
21
- "adapters.pool_adapter.adapter.PoolClient",
22
- return_value=mock_pool_client,
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 address" in data
46
+ assert "No token found for" in data
47
47
 
48
48
  @pytest.mark.asyncio
49
- async def test_get_token_flexible_success(self, adapter):
50
- """Test flexible token retrieval with both address and token_id"""
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 SWAP(BaseModel):
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(BaseModel):
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(BaseModel):
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]