wayfinder-paths 0.1.7__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (149) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +399 -0
  2. wayfinder_paths/__init__.py +22 -0
  3. wayfinder_paths/abis/generic/erc20.json +383 -0
  4. wayfinder_paths/adapters/__init__.py +0 -0
  5. wayfinder_paths/adapters/balance_adapter/README.md +94 -0
  6. wayfinder_paths/adapters/balance_adapter/adapter.py +238 -0
  7. wayfinder_paths/adapters/balance_adapter/examples.json +6 -0
  8. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  9. wayfinder_paths/adapters/balance_adapter/test_adapter.py +59 -0
  10. wayfinder_paths/adapters/brap_adapter/README.md +249 -0
  11. wayfinder_paths/adapters/brap_adapter/__init__.py +7 -0
  12. wayfinder_paths/adapters/brap_adapter/adapter.py +726 -0
  13. wayfinder_paths/adapters/brap_adapter/examples.json +175 -0
  14. wayfinder_paths/adapters/brap_adapter/manifest.yaml +11 -0
  15. wayfinder_paths/adapters/brap_adapter/test_adapter.py +286 -0
  16. wayfinder_paths/adapters/hyperlend_adapter/__init__.py +7 -0
  17. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +305 -0
  18. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +10 -0
  19. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +274 -0
  20. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
  21. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
  22. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
  23. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
  24. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
  25. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
  26. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
  27. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
  28. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
  29. wayfinder_paths/adapters/ledger_adapter/README.md +145 -0
  30. wayfinder_paths/adapters/ledger_adapter/__init__.py +7 -0
  31. wayfinder_paths/adapters/ledger_adapter/adapter.py +289 -0
  32. wayfinder_paths/adapters/ledger_adapter/examples.json +137 -0
  33. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +11 -0
  34. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +205 -0
  35. wayfinder_paths/adapters/pool_adapter/README.md +206 -0
  36. wayfinder_paths/adapters/pool_adapter/__init__.py +7 -0
  37. wayfinder_paths/adapters/pool_adapter/adapter.py +282 -0
  38. wayfinder_paths/adapters/pool_adapter/examples.json +143 -0
  39. wayfinder_paths/adapters/pool_adapter/manifest.yaml +10 -0
  40. wayfinder_paths/adapters/pool_adapter/test_adapter.py +220 -0
  41. wayfinder_paths/adapters/token_adapter/README.md +101 -0
  42. wayfinder_paths/adapters/token_adapter/__init__.py +3 -0
  43. wayfinder_paths/adapters/token_adapter/adapter.py +96 -0
  44. wayfinder_paths/adapters/token_adapter/examples.json +26 -0
  45. wayfinder_paths/adapters/token_adapter/manifest.yaml +6 -0
  46. wayfinder_paths/adapters/token_adapter/test_adapter.py +125 -0
  47. wayfinder_paths/config.example.json +22 -0
  48. wayfinder_paths/conftest.py +31 -0
  49. wayfinder_paths/core/__init__.py +18 -0
  50. wayfinder_paths/core/adapters/BaseAdapter.py +65 -0
  51. wayfinder_paths/core/adapters/__init__.py +5 -0
  52. wayfinder_paths/core/adapters/base.py +5 -0
  53. wayfinder_paths/core/adapters/models.py +46 -0
  54. wayfinder_paths/core/analytics/__init__.py +11 -0
  55. wayfinder_paths/core/analytics/bootstrap.py +57 -0
  56. wayfinder_paths/core/analytics/stats.py +48 -0
  57. wayfinder_paths/core/analytics/test_analytics.py +170 -0
  58. wayfinder_paths/core/clients/AuthClient.py +83 -0
  59. wayfinder_paths/core/clients/BRAPClient.py +109 -0
  60. wayfinder_paths/core/clients/ClientManager.py +210 -0
  61. wayfinder_paths/core/clients/HyperlendClient.py +192 -0
  62. wayfinder_paths/core/clients/LedgerClient.py +443 -0
  63. wayfinder_paths/core/clients/PoolClient.py +128 -0
  64. wayfinder_paths/core/clients/SimulationClient.py +192 -0
  65. wayfinder_paths/core/clients/TokenClient.py +89 -0
  66. wayfinder_paths/core/clients/TransactionClient.py +63 -0
  67. wayfinder_paths/core/clients/WalletClient.py +94 -0
  68. wayfinder_paths/core/clients/WayfinderClient.py +269 -0
  69. wayfinder_paths/core/clients/__init__.py +48 -0
  70. wayfinder_paths/core/clients/protocols.py +392 -0
  71. wayfinder_paths/core/clients/sdk_example.py +110 -0
  72. wayfinder_paths/core/config.py +458 -0
  73. wayfinder_paths/core/constants/__init__.py +26 -0
  74. wayfinder_paths/core/constants/base.py +42 -0
  75. wayfinder_paths/core/constants/erc20_abi.py +118 -0
  76. wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
  77. wayfinder_paths/core/engine/StrategyJob.py +188 -0
  78. wayfinder_paths/core/engine/__init__.py +5 -0
  79. wayfinder_paths/core/engine/manifest.py +97 -0
  80. wayfinder_paths/core/services/__init__.py +0 -0
  81. wayfinder_paths/core/services/base.py +179 -0
  82. wayfinder_paths/core/services/local_evm_txn.py +430 -0
  83. wayfinder_paths/core/services/local_token_txn.py +231 -0
  84. wayfinder_paths/core/services/web3_service.py +45 -0
  85. wayfinder_paths/core/settings.py +61 -0
  86. wayfinder_paths/core/strategies/Strategy.py +280 -0
  87. wayfinder_paths/core/strategies/__init__.py +5 -0
  88. wayfinder_paths/core/strategies/base.py +7 -0
  89. wayfinder_paths/core/strategies/descriptors.py +81 -0
  90. wayfinder_paths/core/utils/__init__.py +1 -0
  91. wayfinder_paths/core/utils/evm_helpers.py +206 -0
  92. wayfinder_paths/core/utils/wallets.py +77 -0
  93. wayfinder_paths/core/wallets/README.md +91 -0
  94. wayfinder_paths/core/wallets/WalletManager.py +56 -0
  95. wayfinder_paths/core/wallets/__init__.py +7 -0
  96. wayfinder_paths/policies/enso.py +17 -0
  97. wayfinder_paths/policies/erc20.py +34 -0
  98. wayfinder_paths/policies/evm.py +21 -0
  99. wayfinder_paths/policies/hyper_evm.py +19 -0
  100. wayfinder_paths/policies/hyperlend.py +12 -0
  101. wayfinder_paths/policies/hyperliquid.py +30 -0
  102. wayfinder_paths/policies/moonwell.py +54 -0
  103. wayfinder_paths/policies/prjx.py +30 -0
  104. wayfinder_paths/policies/util.py +27 -0
  105. wayfinder_paths/run_strategy.py +411 -0
  106. wayfinder_paths/scripts/__init__.py +0 -0
  107. wayfinder_paths/scripts/create_strategy.py +181 -0
  108. wayfinder_paths/scripts/make_wallets.py +169 -0
  109. wayfinder_paths/scripts/run_strategy.py +124 -0
  110. wayfinder_paths/scripts/validate_manifests.py +213 -0
  111. wayfinder_paths/strategies/__init__.py +0 -0
  112. wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
  113. wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
  114. wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
  115. wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
  116. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
  117. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
  118. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
  119. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
  120. wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
  121. wayfinder_paths/strategies/config.py +85 -0
  122. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +100 -0
  123. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +8 -0
  124. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
  125. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2270 -0
  126. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +352 -0
  127. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +96 -0
  128. wayfinder_paths/strategies/stablecoin_yield_strategy/examples.json +17 -0
  129. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
  130. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1810 -0
  131. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +520 -0
  132. wayfinder_paths/templates/adapter/README.md +105 -0
  133. wayfinder_paths/templates/adapter/adapter.py +26 -0
  134. wayfinder_paths/templates/adapter/examples.json +8 -0
  135. wayfinder_paths/templates/adapter/manifest.yaml +6 -0
  136. wayfinder_paths/templates/adapter/test_adapter.py +49 -0
  137. wayfinder_paths/templates/strategy/README.md +153 -0
  138. wayfinder_paths/templates/strategy/examples.json +11 -0
  139. wayfinder_paths/templates/strategy/manifest.yaml +8 -0
  140. wayfinder_paths/templates/strategy/strategy.py +57 -0
  141. wayfinder_paths/templates/strategy/test_strategy.py +197 -0
  142. wayfinder_paths/tests/__init__.py +0 -0
  143. wayfinder_paths/tests/test_smoke_manifest.py +48 -0
  144. wayfinder_paths/tests/test_test_coverage.py +212 -0
  145. wayfinder_paths/tests/test_utils.py +64 -0
  146. wayfinder_paths-0.1.7.dist-info/LICENSE +21 -0
  147. wayfinder_paths-0.1.7.dist-info/METADATA +777 -0
  148. wayfinder_paths-0.1.7.dist-info/RECORD +149 -0
  149. wayfinder_paths-0.1.7.dist-info/WHEEL +4 -0
@@ -0,0 +1,125 @@
1
+ from unittest.mock import patch
2
+
3
+ import pytest
4
+
5
+ from wayfinder_paths.adapters.token_adapter.adapter import TokenAdapter
6
+
7
+
8
+ class TestTokenAdapter:
9
+ """Test cases for TokenAdapter"""
10
+
11
+ @pytest.fixture
12
+ def adapter(self):
13
+ return TokenAdapter()
14
+
15
+ def test_init_with_default_config(self):
16
+ """Test adapter initialization with default config"""
17
+ adapter = TokenAdapter()
18
+ assert adapter.adapter_type == "TOKEN"
19
+ assert adapter.token_client is not None
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_get_token_success(self, adapter):
23
+ """Test successful token retrieval by address"""
24
+ mock_token_data = {
25
+ "address": "0x1234...",
26
+ "symbol": "TEST",
27
+ "name": "Test Token",
28
+ "decimals": 18,
29
+ }
30
+
31
+ with patch.object(
32
+ adapter.token_client, "get_token_details", return_value=mock_token_data
33
+ ):
34
+ success, data = await adapter.get_token("0x1234...")
35
+
36
+ assert success is True
37
+ assert data == mock_token_data
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_get_token_not_found(self, adapter):
41
+ """Test token not found by address"""
42
+ with patch.object(adapter.token_client, "get_token_details", return_value=None):
43
+ success, data = await adapter.get_token("0x1234...")
44
+
45
+ assert success is False
46
+ assert "No token found for" in data
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_get_token_by_token_id(self, adapter):
50
+ """Test token retrieval with token_id"""
51
+ mock_token_data = {"address": "0x1234...", "symbol": "TEST"}
52
+
53
+ with patch.object(
54
+ adapter.token_client, "get_token_details", return_value=mock_token_data
55
+ ):
56
+ success, data = await adapter.get_token("token-123")
57
+
58
+ assert success is True
59
+ assert data == mock_token_data
60
+
61
+ def test_adapter_type(self, adapter):
62
+ """Test adapter has adapter_type"""
63
+ assert adapter.adapter_type == "TOKEN"
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_get_token_price_success(self, adapter):
67
+ """Test successful token price retrieval"""
68
+ mock_token_data = {
69
+ "current_price": 1.50,
70
+ "price_change_24h": 0.05,
71
+ "price_change_percentage_24h": 3.45,
72
+ "market_cap": 1000000,
73
+ "total_volume": 50000,
74
+ "symbol": "TEST",
75
+ "name": "Test Token",
76
+ "address": "0x1234...",
77
+ }
78
+
79
+ with patch.object(
80
+ adapter.token_client, "get_token_details", return_value=mock_token_data
81
+ ):
82
+ success, data = await adapter.get_token_price("test-token")
83
+
84
+ assert success is True
85
+ assert data["current_price"] == 1.50
86
+ assert data["symbol"] == "TEST"
87
+ assert data["name"] == "Test Token"
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_get_token_price_not_found(self, adapter):
91
+ """Test token price not found"""
92
+ with patch.object(adapter.token_client, "get_token_details", return_value=None):
93
+ success, data = await adapter.get_token_price("invalid-token")
94
+
95
+ assert success is False
96
+ assert "No token found for" in data
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_get_gas_token_success(self, adapter):
100
+ """Test successful gas token retrieval"""
101
+ mock_gas_token_data = {
102
+ "id": "ethereum-base",
103
+ "symbol": "ETH",
104
+ "name": "Ethereum",
105
+ "address": "0x0000000000000000000000000000000000000000",
106
+ "decimals": 18,
107
+ }
108
+
109
+ with patch.object(
110
+ adapter.token_client, "get_gas_token", return_value=mock_gas_token_data
111
+ ):
112
+ success, data = await adapter.get_gas_token("base")
113
+
114
+ assert success is True
115
+ assert data["symbol"] == "ETH"
116
+ assert data["name"] == "Ethereum"
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_get_gas_token_not_found(self, adapter):
120
+ """Test gas token not found"""
121
+ with patch.object(adapter.token_client, "get_gas_token", return_value=None):
122
+ success, data = await adapter.get_gas_token("invalid-chain")
123
+
124
+ assert success is False
125
+ assert "No gas token found for chain" in data
@@ -0,0 +1,22 @@
1
+ {
2
+ "user": {
3
+ "username": "your_username",
4
+ "password": "your_password",
5
+ "refresh_token": null,
6
+ "api_key": "sk_live_abc123..."
7
+ },
8
+ "system": {
9
+ "api_base_url": "https://wayfinder.ai/api/v1",
10
+ "api_key": "sk_live_abc123...",
11
+ "wallets_path": "wallets.json"
12
+ },
13
+ "strategy": {
14
+ "rpc_urls": {
15
+ "1": "https://eth.llamarpc.com",
16
+ "42161": "https://arb1.arbitrum.io/rpc",
17
+ "8453": "https://mainnet.base.org",
18
+ "solana": "https://api.mainnet-beta.solana.com",
19
+ "999": "https://rpc.hyperliquid.xyz/evm"
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,31 @@
1
+ """
2
+ Conftest for wayfinder-paths package tests.
3
+ Adds wayfinder-paths directory to Python path for imports.
4
+ This must run early, so imports like 'from tests.test_utils' work.
5
+ """
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ # Add wayfinder-paths directory to Python path for imports (for tests.test_utils)
11
+ # This needs to be at index 0 to take precedence over repo root 'tests/' directory
12
+ _wayfinder_path_dir = Path(__file__).parent
13
+ _wayfinder_path_str = str(_wayfinder_path_dir)
14
+
15
+
16
+ def pytest_configure(config):
17
+ """Configure pytest - runs early to set up imports."""
18
+ if _wayfinder_path_str not in sys.path:
19
+ sys.path.insert(0, _wayfinder_path_str)
20
+ elif sys.path.index(_wayfinder_path_str) > 0:
21
+ # Move to front if it exists but isn't first
22
+ sys.path.remove(_wayfinder_path_str)
23
+ sys.path.insert(0, _wayfinder_path_str)
24
+
25
+
26
+ # Also set it immediately (in case pytest_configure hasn't run yet)
27
+ if _wayfinder_path_str not in sys.path:
28
+ sys.path.insert(0, _wayfinder_path_str)
29
+ elif sys.path.index(_wayfinder_path_str) > 0:
30
+ sys.path.remove(_wayfinder_path_str)
31
+ sys.path.insert(0, _wayfinder_path_str)
@@ -0,0 +1,18 @@
1
+ """Wayfinder Paths Core Engine"""
2
+
3
+ from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
4
+ from wayfinder_paths.core.engine.StrategyJob import StrategyJob
5
+ from wayfinder_paths.core.strategies.Strategy import (
6
+ LiquidationResult,
7
+ StatusDict,
8
+ StatusTuple,
9
+ Strategy,
10
+ )
11
+
12
+ __all__ = [
13
+ "Strategy",
14
+ "StatusDict",
15
+ "StatusTuple",
16
+ "BaseAdapter",
17
+ "StrategyJob",
18
+ ]
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC
4
+ from typing import Any
5
+
6
+ from loguru import logger
7
+
8
+
9
+ class BaseAdapter(ABC):
10
+ """Base adapter class for exchange/protocol integrations"""
11
+
12
+ adapter_type: str | None = None
13
+
14
+ def __init__(self, name: str, config: dict[str, Any] | None = None):
15
+ self.name = name
16
+ self.config = config or {}
17
+ self.logger = logger.bind(adapter=self.__class__.__name__)
18
+
19
+ async def connect(self) -> bool:
20
+ """Optional: establish connectivity. Defaults to True."""
21
+ return True
22
+
23
+ async def get_balance(self, asset: str) -> dict[str, Any]:
24
+ """
25
+ Get balance for an asset.
26
+ Optional method that can be overridden by subclasses.
27
+
28
+ Args:
29
+ asset: Asset identifier (token address, token ID, etc.).
30
+
31
+ Returns:
32
+ Dictionary containing balance information.
33
+
34
+ Raises:
35
+ ValueError: If asset is empty or invalid.
36
+ NotImplementedError: If this adapter does not support balance queries.
37
+ """
38
+ if not asset or not isinstance(asset, str) or not asset.strip():
39
+ raise ValueError("asset must be a non-empty string")
40
+ raise NotImplementedError(
41
+ f"get_balance not supported by {self.__class__.__name__}"
42
+ )
43
+
44
+ async def health_check(self) -> dict[str, Any]:
45
+ """
46
+ Check adapter health and connectivity
47
+ Returns: Health status dictionary
48
+ """
49
+ try:
50
+ connected = await self.connect()
51
+ return {
52
+ "status": "healthy" if connected else "unhealthy",
53
+ "connected": connected,
54
+ "adapter": self.adapter_type or self.__class__.__name__,
55
+ }
56
+ except Exception as e:
57
+ return {
58
+ "status": "error",
59
+ "error": str(e),
60
+ "adapter": self.adapter_type or self.__class__.__name__,
61
+ }
62
+
63
+ async def close(self) -> None:
64
+ """Clean up resources"""
65
+ pass
@@ -0,0 +1,5 @@
1
+ """Core Adapter Module - SDK surface re-export"""
2
+
3
+ from .base import BaseAdapter
4
+
5
+ __all__ = ["BaseAdapter"]
@@ -0,0 +1,5 @@
1
+ from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
2
+
3
+ __all__ = [
4
+ "BaseAdapter",
5
+ ]
@@ -0,0 +1,46 @@
1
+ """Pydantic models for ledger operations."""
2
+
3
+ from typing import Annotated, Any, Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class OperationBase(BaseModel):
9
+ adapter: str
10
+ transaction_hash: str | None
11
+ transaction_chain_id: int | None
12
+
13
+
14
+ class SWAP(OperationBase):
15
+ """Swap operation."""
16
+
17
+ type: Literal["SWAP"] = "SWAP"
18
+ from_token_id: str
19
+ to_token_id: str
20
+ from_amount: str
21
+ to_amount: str
22
+ from_amount_usd: float
23
+ to_amount_usd: float
24
+ transaction_status: str | None = None
25
+ transaction_receipt: dict[str, Any] | None = None
26
+
27
+
28
+ class LEND(OperationBase):
29
+ type: Literal["LEND"] = "LEND"
30
+ contract: str
31
+ amount: int
32
+
33
+
34
+ class UNLEND(OperationBase):
35
+ type: Literal["UNLEND"] = "UNLEND"
36
+ contract: str
37
+ amount: int
38
+
39
+
40
+ # Type alias for operation types (currently only SWAP is used)
41
+ # Add more operation types here as needed
42
+ Operation = SWAP | LEND | UNLEND
43
+
44
+
45
+ class STRAT_OP(BaseModel):
46
+ op_data: Annotated[Operation, Field(discriminator="type")]
@@ -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