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.
- wayfinder_paths/CONFIG_GUIDE.md +399 -0
- wayfinder_paths/__init__.py +22 -0
- wayfinder_paths/abis/generic/erc20.json +383 -0
- wayfinder_paths/adapters/__init__.py +0 -0
- wayfinder_paths/adapters/balance_adapter/README.md +94 -0
- wayfinder_paths/adapters/balance_adapter/adapter.py +238 -0
- wayfinder_paths/adapters/balance_adapter/examples.json +6 -0
- wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +59 -0
- wayfinder_paths/adapters/brap_adapter/README.md +249 -0
- wayfinder_paths/adapters/brap_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/brap_adapter/adapter.py +726 -0
- wayfinder_paths/adapters/brap_adapter/examples.json +175 -0
- wayfinder_paths/adapters/brap_adapter/manifest.yaml +11 -0
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +286 -0
- wayfinder_paths/adapters/hyperlend_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +305 -0
- wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +10 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +274 -0
- 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/README.md +145 -0
- wayfinder_paths/adapters/ledger_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/ledger_adapter/adapter.py +289 -0
- wayfinder_paths/adapters/ledger_adapter/examples.json +137 -0
- wayfinder_paths/adapters/ledger_adapter/manifest.yaml +11 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +205 -0
- wayfinder_paths/adapters/pool_adapter/README.md +206 -0
- wayfinder_paths/adapters/pool_adapter/__init__.py +7 -0
- wayfinder_paths/adapters/pool_adapter/adapter.py +282 -0
- wayfinder_paths/adapters/pool_adapter/examples.json +143 -0
- wayfinder_paths/adapters/pool_adapter/manifest.yaml +10 -0
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +220 -0
- wayfinder_paths/adapters/token_adapter/README.md +101 -0
- wayfinder_paths/adapters/token_adapter/__init__.py +3 -0
- wayfinder_paths/adapters/token_adapter/adapter.py +96 -0
- wayfinder_paths/adapters/token_adapter/examples.json +26 -0
- wayfinder_paths/adapters/token_adapter/manifest.yaml +6 -0
- wayfinder_paths/adapters/token_adapter/test_adapter.py +125 -0
- wayfinder_paths/config.example.json +22 -0
- wayfinder_paths/conftest.py +31 -0
- wayfinder_paths/core/__init__.py +18 -0
- wayfinder_paths/core/adapters/BaseAdapter.py +65 -0
- wayfinder_paths/core/adapters/__init__.py +5 -0
- wayfinder_paths/core/adapters/base.py +5 -0
- wayfinder_paths/core/adapters/models.py +46 -0
- 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/AuthClient.py +83 -0
- wayfinder_paths/core/clients/BRAPClient.py +109 -0
- wayfinder_paths/core/clients/ClientManager.py +210 -0
- wayfinder_paths/core/clients/HyperlendClient.py +192 -0
- wayfinder_paths/core/clients/LedgerClient.py +443 -0
- wayfinder_paths/core/clients/PoolClient.py +128 -0
- wayfinder_paths/core/clients/SimulationClient.py +192 -0
- wayfinder_paths/core/clients/TokenClient.py +89 -0
- wayfinder_paths/core/clients/TransactionClient.py +63 -0
- wayfinder_paths/core/clients/WalletClient.py +94 -0
- wayfinder_paths/core/clients/WayfinderClient.py +269 -0
- wayfinder_paths/core/clients/__init__.py +48 -0
- wayfinder_paths/core/clients/protocols.py +392 -0
- wayfinder_paths/core/clients/sdk_example.py +110 -0
- wayfinder_paths/core/config.py +458 -0
- wayfinder_paths/core/constants/__init__.py +26 -0
- wayfinder_paths/core/constants/base.py +42 -0
- wayfinder_paths/core/constants/erc20_abi.py +118 -0
- wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
- wayfinder_paths/core/engine/StrategyJob.py +188 -0
- wayfinder_paths/core/engine/__init__.py +5 -0
- wayfinder_paths/core/engine/manifest.py +97 -0
- wayfinder_paths/core/services/__init__.py +0 -0
- wayfinder_paths/core/services/base.py +179 -0
- wayfinder_paths/core/services/local_evm_txn.py +430 -0
- wayfinder_paths/core/services/local_token_txn.py +231 -0
- wayfinder_paths/core/services/web3_service.py +45 -0
- wayfinder_paths/core/settings.py +61 -0
- wayfinder_paths/core/strategies/Strategy.py +280 -0
- wayfinder_paths/core/strategies/__init__.py +5 -0
- wayfinder_paths/core/strategies/base.py +7 -0
- wayfinder_paths/core/strategies/descriptors.py +81 -0
- wayfinder_paths/core/utils/__init__.py +1 -0
- wayfinder_paths/core/utils/evm_helpers.py +206 -0
- wayfinder_paths/core/utils/wallets.py +77 -0
- wayfinder_paths/core/wallets/README.md +91 -0
- wayfinder_paths/core/wallets/WalletManager.py +56 -0
- wayfinder_paths/core/wallets/__init__.py +7 -0
- wayfinder_paths/policies/enso.py +17 -0
- wayfinder_paths/policies/erc20.py +34 -0
- wayfinder_paths/policies/evm.py +21 -0
- wayfinder_paths/policies/hyper_evm.py +19 -0
- wayfinder_paths/policies/hyperlend.py +12 -0
- wayfinder_paths/policies/hyperliquid.py +30 -0
- wayfinder_paths/policies/moonwell.py +54 -0
- wayfinder_paths/policies/prjx.py +30 -0
- wayfinder_paths/policies/util.py +27 -0
- wayfinder_paths/run_strategy.py +411 -0
- wayfinder_paths/scripts/__init__.py +0 -0
- wayfinder_paths/scripts/create_strategy.py +181 -0
- wayfinder_paths/scripts/make_wallets.py +169 -0
- wayfinder_paths/scripts/run_strategy.py +124 -0
- wayfinder_paths/scripts/validate_manifests.py +213 -0
- wayfinder_paths/strategies/__init__.py +0 -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/config.py +85 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +100 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +8 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2270 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +352 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +96 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/examples.json +17 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1810 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +520 -0
- wayfinder_paths/templates/adapter/README.md +105 -0
- wayfinder_paths/templates/adapter/adapter.py +26 -0
- wayfinder_paths/templates/adapter/examples.json +8 -0
- wayfinder_paths/templates/adapter/manifest.yaml +6 -0
- wayfinder_paths/templates/adapter/test_adapter.py +49 -0
- wayfinder_paths/templates/strategy/README.md +153 -0
- wayfinder_paths/templates/strategy/examples.json +11 -0
- wayfinder_paths/templates/strategy/manifest.yaml +8 -0
- wayfinder_paths/templates/strategy/strategy.py +57 -0
- wayfinder_paths/templates/strategy/test_strategy.py +197 -0
- wayfinder_paths/tests/__init__.py +0 -0
- wayfinder_paths/tests/test_smoke_manifest.py +48 -0
- wayfinder_paths/tests/test_test_coverage.py +212 -0
- wayfinder_paths/tests/test_utils.py +64 -0
- wayfinder_paths-0.1.7.dist-info/LICENSE +21 -0
- wayfinder_paths-0.1.7.dist-info/METADATA +777 -0
- wayfinder_paths-0.1.7.dist-info/RECORD +149 -0
- 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,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
|