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,520 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest.mock import AsyncMock
|
|
4
|
+
|
|
5
|
+
# Ensure wayfinder-paths is on path for tests.test_utils import
|
|
6
|
+
# This is a workaround until conftest loading order is resolved
|
|
7
|
+
_wayfinder_path_dir = Path(__file__).parent.parent.parent.resolve()
|
|
8
|
+
_wayfinder_path_str = str(_wayfinder_path_dir)
|
|
9
|
+
if _wayfinder_path_str not in sys.path:
|
|
10
|
+
sys.path.insert(0, _wayfinder_path_str)
|
|
11
|
+
elif sys.path.index(_wayfinder_path_str) > 0:
|
|
12
|
+
# Move to front to take precedence
|
|
13
|
+
sys.path.remove(_wayfinder_path_str)
|
|
14
|
+
sys.path.insert(0, _wayfinder_path_str)
|
|
15
|
+
|
|
16
|
+
import pytest # noqa: E402
|
|
17
|
+
|
|
18
|
+
# Import test utilities
|
|
19
|
+
try:
|
|
20
|
+
from tests.test_utils import get_canonical_examples, load_strategy_examples
|
|
21
|
+
except ImportError:
|
|
22
|
+
# Fallback if path setup didn't work
|
|
23
|
+
import importlib.util
|
|
24
|
+
|
|
25
|
+
test_utils_path = Path(_wayfinder_path_dir) / "tests" / "test_utils.py"
|
|
26
|
+
spec = importlib.util.spec_from_file_location("tests.test_utils", test_utils_path)
|
|
27
|
+
test_utils = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(test_utils)
|
|
29
|
+
get_canonical_examples = test_utils.get_canonical_examples
|
|
30
|
+
load_strategy_examples = test_utils.load_strategy_examples
|
|
31
|
+
|
|
32
|
+
from wayfinder_paths.strategies.stablecoin_yield_strategy.strategy import ( # noqa: E402
|
|
33
|
+
StablecoinYieldStrategy,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def strategy():
|
|
39
|
+
"""Create a strategy instance for testing with minimal config."""
|
|
40
|
+
mock_config = {
|
|
41
|
+
"main_wallet": {"address": "0x1234567890123456789012345678901234567890"},
|
|
42
|
+
"strategy_wallet": {"address": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
s = StablecoinYieldStrategy(
|
|
46
|
+
config=mock_config,
|
|
47
|
+
main_wallet=mock_config["main_wallet"],
|
|
48
|
+
strategy_wallet=mock_config["strategy_wallet"],
|
|
49
|
+
simulation=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if hasattr(s, "balance_adapter") and s.balance_adapter:
|
|
53
|
+
usdc_balance_mock = AsyncMock(return_value=(True, 60000000))
|
|
54
|
+
gas_balance_mock = AsyncMock(return_value=(True, 2000000000000000))
|
|
55
|
+
|
|
56
|
+
def get_balance_side_effect(token_id, wallet_address, **kwargs):
|
|
57
|
+
if token_id == "usd-coin-base" or token_id == "usd-coin":
|
|
58
|
+
return usdc_balance_mock.return_value
|
|
59
|
+
elif token_id == "ethereum-base" or token_id == "ethereum":
|
|
60
|
+
return gas_balance_mock.return_value
|
|
61
|
+
return (True, 1000000000)
|
|
62
|
+
|
|
63
|
+
s.balance_adapter.get_balance = AsyncMock(side_effect=get_balance_side_effect)
|
|
64
|
+
|
|
65
|
+
if hasattr(s, "token_adapter") and s.token_adapter:
|
|
66
|
+
default_usdc = {
|
|
67
|
+
"id": "usd-coin-base",
|
|
68
|
+
"symbol": "USDC",
|
|
69
|
+
"name": "USD Coin",
|
|
70
|
+
"decimals": 6,
|
|
71
|
+
"address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
72
|
+
"chain": {"code": "base", "id": 8453, "name": "Base"},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
default_pool_token = {
|
|
76
|
+
"id": "test-pool-base",
|
|
77
|
+
"symbol": "POOL",
|
|
78
|
+
"name": "Test Pool",
|
|
79
|
+
"decimals": 18,
|
|
80
|
+
"address": "0x1234567890123456789012345678901234567890",
|
|
81
|
+
"chain": {"code": "base", "id": 8453, "name": "Base"},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def get_token_side_effect(address=None, token_id=None, **kwargs):
|
|
85
|
+
if token_id == "usd-coin-base" or token_id == "usd-coin":
|
|
86
|
+
return (True, default_usdc)
|
|
87
|
+
elif (
|
|
88
|
+
token_id == "test-pool-base"
|
|
89
|
+
or address == "0x1234567890123456789012345678901234567890"
|
|
90
|
+
):
|
|
91
|
+
return (True, default_pool_token)
|
|
92
|
+
return (True, default_usdc)
|
|
93
|
+
|
|
94
|
+
s.token_adapter.get_token = AsyncMock(side_effect=get_token_side_effect)
|
|
95
|
+
s.token_adapter.get_gas_token = AsyncMock(
|
|
96
|
+
return_value=(
|
|
97
|
+
True,
|
|
98
|
+
{
|
|
99
|
+
"id": "ethereum-base",
|
|
100
|
+
"symbol": "ETH",
|
|
101
|
+
"name": "Ethereum",
|
|
102
|
+
"decimals": 18,
|
|
103
|
+
"address": "0x4200000000000000000000000000000000000006",
|
|
104
|
+
"chain": {"code": "base", "id": 8453, "name": "Base"},
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if hasattr(s, "balance_adapter") and s.balance_adapter:
|
|
110
|
+
s.balance_adapter.move_from_main_wallet_to_strategy_wallet = AsyncMock(
|
|
111
|
+
return_value=(True, "Transfer successful (simulated)")
|
|
112
|
+
)
|
|
113
|
+
s.balance_adapter.move_from_strategy_wallet_to_main_wallet = AsyncMock(
|
|
114
|
+
return_value=(True, "Transfer successful (simulated)")
|
|
115
|
+
)
|
|
116
|
+
if hasattr(s.balance_adapter, "wallet_provider"):
|
|
117
|
+
s.balance_adapter.wallet_provider.broadcast_transaction = AsyncMock(
|
|
118
|
+
return_value=(True, {"transaction_hash": "0xDEADBEEF"})
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if hasattr(s, "ledger_adapter") and s.ledger_adapter:
|
|
122
|
+
s.ledger_adapter.get_strategy_net_deposit = AsyncMock(
|
|
123
|
+
return_value=(True, {"net_deposit": 0})
|
|
124
|
+
)
|
|
125
|
+
s.ledger_adapter.get_strategy_transactions = AsyncMock(
|
|
126
|
+
return_value=(True, {"transactions": []})
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if hasattr(s, "pool_adapter") and s.pool_adapter:
|
|
130
|
+
s.pool_adapter.find_high_yield_pools = AsyncMock(
|
|
131
|
+
return_value=(True, {"pools": [], "total_found": 0})
|
|
132
|
+
)
|
|
133
|
+
s.pool_adapter.get_pools_by_ids = AsyncMock(
|
|
134
|
+
return_value=(
|
|
135
|
+
True,
|
|
136
|
+
{"pools": [{"id": "test-pool-base", "apy": 15.0, "symbol": "POOL"}]},
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
s.pool_adapter.get_llama_matches = AsyncMock(
|
|
140
|
+
return_value=(
|
|
141
|
+
True,
|
|
142
|
+
{
|
|
143
|
+
"matches": [
|
|
144
|
+
{
|
|
145
|
+
"llama_stablecoin": True,
|
|
146
|
+
"llama_il_risk": "no",
|
|
147
|
+
"llama_tvl_usd": 2000000,
|
|
148
|
+
"llama_apy_pct": 5.0,
|
|
149
|
+
"network": "base",
|
|
150
|
+
"address": "0x1234567890123456789012345678901234567890",
|
|
151
|
+
"token_id": "test-pool-base",
|
|
152
|
+
"pool_id": "test-pool-base",
|
|
153
|
+
"llama_combined_apy_pct": 15.0,
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if hasattr(s, "brap_adapter") and s.brap_adapter:
|
|
161
|
+
|
|
162
|
+
def get_swap_quote_side_effect(*args, **kwargs):
|
|
163
|
+
to_token_address = kwargs.get("to_token_address", "")
|
|
164
|
+
if to_token_address == "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913":
|
|
165
|
+
return (
|
|
166
|
+
True,
|
|
167
|
+
{
|
|
168
|
+
"quotes": {
|
|
169
|
+
"best_quote": {
|
|
170
|
+
"output_amount": "99900000",
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
return (
|
|
176
|
+
True,
|
|
177
|
+
{
|
|
178
|
+
"quotes": {
|
|
179
|
+
"best_quote": {
|
|
180
|
+
"output_amount": "105000000",
|
|
181
|
+
"input_amount": "50000000000000",
|
|
182
|
+
"toAmount": "105000000",
|
|
183
|
+
"estimatedGas": "1000000000",
|
|
184
|
+
"fromAmount": "100000000",
|
|
185
|
+
"fromToken": {"symbol": "USDC"},
|
|
186
|
+
"toToken": {"symbol": "POOL"},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
s.brap_adapter.get_swap_quote = AsyncMock(
|
|
193
|
+
side_effect=get_swap_quote_side_effect
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if (
|
|
197
|
+
hasattr(s, "brap_adapter")
|
|
198
|
+
and s.brap_adapter
|
|
199
|
+
and hasattr(s.brap_adapter, "swap_from_quote")
|
|
200
|
+
):
|
|
201
|
+
s.brap_adapter.swap_from_quote = AsyncMock(return_value=None)
|
|
202
|
+
if hasattr(s, "brap_adapter") and hasattr(s.brap_adapter, "wallet_provider"):
|
|
203
|
+
s.brap_adapter.wallet_provider.broadcast_transaction = AsyncMock(
|
|
204
|
+
return_value=(True, {"transaction_hash": "0xBEEF"})
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
s.DEPOSIT_USDC = 0
|
|
208
|
+
s.usdc_token_info = {
|
|
209
|
+
"id": "usd-coin-base",
|
|
210
|
+
"symbol": "USDC",
|
|
211
|
+
"name": "USD Coin",
|
|
212
|
+
"decimals": 6,
|
|
213
|
+
"address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
214
|
+
"chain": {"code": "base", "id": 8453, "name": "Base"},
|
|
215
|
+
}
|
|
216
|
+
s.gas_token = {
|
|
217
|
+
"id": "ethereum-base",
|
|
218
|
+
"symbol": "ETH",
|
|
219
|
+
"name": "Ethereum",
|
|
220
|
+
"decimals": 18,
|
|
221
|
+
"address": "0x4200000000000000000000000000000000000006",
|
|
222
|
+
"chain": {"code": "base", "id": 8453, "name": "Base"},
|
|
223
|
+
}
|
|
224
|
+
s.current_pool = {
|
|
225
|
+
"id": "usd-coin-base",
|
|
226
|
+
"symbol": "USDC",
|
|
227
|
+
"decimals": 6,
|
|
228
|
+
"chain": {"code": "base", "id": 8453, "name": "Base"},
|
|
229
|
+
}
|
|
230
|
+
s.current_pool_balance = 100000000
|
|
231
|
+
s.current_combined_apy_pct = 0.0
|
|
232
|
+
s.current_pool_data = None
|
|
233
|
+
|
|
234
|
+
if hasattr(s, "token_adapter") and s.token_adapter:
|
|
235
|
+
if not hasattr(s.token_adapter, "get_token_price"):
|
|
236
|
+
s.token_adapter.get_token_price = AsyncMock()
|
|
237
|
+
|
|
238
|
+
def get_token_price_side_effect(token_id):
|
|
239
|
+
if token_id == "ethereum-base":
|
|
240
|
+
return (True, {"current_price": 2000.0})
|
|
241
|
+
else:
|
|
242
|
+
return (True, {"current_price": 1.0})
|
|
243
|
+
|
|
244
|
+
s.token_adapter.get_token_price = AsyncMock(
|
|
245
|
+
side_effect=get_token_price_side_effect
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
async def mock_sweep_wallet(target_token):
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
async def mock_refresh_current_pool_balance():
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
async def mock_rebalance_gas(target_pool):
|
|
255
|
+
return (True, "Gas rebalanced")
|
|
256
|
+
|
|
257
|
+
async def mock_has_idle_assets(balances, target):
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
s._sweep_wallet = mock_sweep_wallet
|
|
261
|
+
s._refresh_current_pool_balance = mock_refresh_current_pool_balance
|
|
262
|
+
s._rebalance_gas = mock_rebalance_gas
|
|
263
|
+
s._has_idle_assets = mock_has_idle_assets
|
|
264
|
+
|
|
265
|
+
return s
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@pytest.mark.asyncio
|
|
269
|
+
@pytest.mark.smoke
|
|
270
|
+
async def test_smoke(strategy):
|
|
271
|
+
"""REQUIRED: Basic smoke test - verifies strategy lifecycle."""
|
|
272
|
+
examples = load_strategy_examples(Path(__file__))
|
|
273
|
+
smoke_data = examples["smoke"]
|
|
274
|
+
|
|
275
|
+
st = await strategy.status()
|
|
276
|
+
assert isinstance(st, dict)
|
|
277
|
+
assert "portfolio_value" in st or "net_deposit" in st or "strategy_status" in st
|
|
278
|
+
|
|
279
|
+
deposit_params = smoke_data.get("deposit", {})
|
|
280
|
+
ok, msg = await strategy.deposit(**deposit_params)
|
|
281
|
+
assert isinstance(ok, bool)
|
|
282
|
+
assert isinstance(msg, str)
|
|
283
|
+
|
|
284
|
+
ok, msg = await strategy.update(**smoke_data.get("update", {}))
|
|
285
|
+
assert isinstance(ok, bool)
|
|
286
|
+
|
|
287
|
+
ok, msg = await strategy.withdraw(**smoke_data.get("withdraw", {}))
|
|
288
|
+
assert isinstance(ok, bool)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@pytest.mark.asyncio
|
|
292
|
+
async def test_canonical_usage(strategy):
|
|
293
|
+
"""REQUIRED: Test canonical usage examples from examples.json (minimum).
|
|
294
|
+
|
|
295
|
+
Canonical usage = all positive usage examples (excluding error cases).
|
|
296
|
+
This is the MINIMUM requirement - feel free to add more test cases here.
|
|
297
|
+
"""
|
|
298
|
+
examples = load_strategy_examples(Path(__file__))
|
|
299
|
+
canonical = get_canonical_examples(examples)
|
|
300
|
+
|
|
301
|
+
for example_name, example_data in canonical.items():
|
|
302
|
+
if "deposit" in example_data:
|
|
303
|
+
deposit_params = example_data.get("deposit", {})
|
|
304
|
+
ok, _ = await strategy.deposit(**deposit_params)
|
|
305
|
+
assert ok, f"Canonical example '{example_name}' deposit failed"
|
|
306
|
+
|
|
307
|
+
if "update" in example_data:
|
|
308
|
+
ok, msg = await strategy.update()
|
|
309
|
+
assert ok, f"Canonical example '{example_name}' update failed: {msg}"
|
|
310
|
+
|
|
311
|
+
if "status" in example_data:
|
|
312
|
+
st = await strategy.status()
|
|
313
|
+
assert isinstance(st, dict), (
|
|
314
|
+
f"Canonical example '{example_name}' status failed"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@pytest.mark.asyncio
|
|
319
|
+
async def test_error_cases(strategy):
|
|
320
|
+
"""OPTIONAL: Test error scenarios from examples.json."""
|
|
321
|
+
examples = load_strategy_examples(Path(__file__))
|
|
322
|
+
|
|
323
|
+
for example_name, example_data in examples.items():
|
|
324
|
+
if isinstance(example_data, dict) and "expect" in example_data:
|
|
325
|
+
expect = example_data.get("expect", {})
|
|
326
|
+
|
|
327
|
+
if "deposit" in example_data:
|
|
328
|
+
deposit_params = example_data.get("deposit", {})
|
|
329
|
+
ok, _ = await strategy.deposit(**deposit_params)
|
|
330
|
+
|
|
331
|
+
if expect.get("success") is False:
|
|
332
|
+
assert ok is False, (
|
|
333
|
+
f"Expected {example_name} deposit to fail but it succeeded"
|
|
334
|
+
)
|
|
335
|
+
elif expect.get("success") is True:
|
|
336
|
+
assert ok is True, (
|
|
337
|
+
f"Expected {example_name} deposit to succeed but it failed"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if "update" in example_data:
|
|
341
|
+
ok, _ = await strategy.update()
|
|
342
|
+
if "success" in expect:
|
|
343
|
+
expected_success = expect.get("success")
|
|
344
|
+
assert ok == expected_success, (
|
|
345
|
+
f"Expected {example_name} update to "
|
|
346
|
+
f"{'succeed' if expected_success else 'fail'} but got opposite"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@pytest.mark.asyncio
|
|
351
|
+
async def test_token_tracking_initialization(strategy):
|
|
352
|
+
"""Test that tracked_token_ids and tracked_balances are initialized."""
|
|
353
|
+
assert hasattr(strategy, "tracked_token_ids")
|
|
354
|
+
assert hasattr(strategy, "tracked_balances")
|
|
355
|
+
assert isinstance(strategy.tracked_token_ids, set)
|
|
356
|
+
assert isinstance(strategy.tracked_balances, dict)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@pytest.mark.asyncio
|
|
360
|
+
async def test_track_token(strategy):
|
|
361
|
+
"""Test that _track_token adds tokens to tracked state."""
|
|
362
|
+
test_token_id = "test-token-base"
|
|
363
|
+
test_balance = 1000000
|
|
364
|
+
|
|
365
|
+
strategy._track_token(test_token_id, test_balance)
|
|
366
|
+
|
|
367
|
+
assert test_token_id in strategy.tracked_token_ids
|
|
368
|
+
assert strategy.tracked_balances.get(test_token_id) == test_balance
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@pytest.mark.asyncio
|
|
372
|
+
async def test_update_balance(strategy):
|
|
373
|
+
"""Test that _update_balance updates tracked balances."""
|
|
374
|
+
test_token_id = "test-token-base"
|
|
375
|
+
initial_balance = 1000000
|
|
376
|
+
updated_balance = 2000000
|
|
377
|
+
|
|
378
|
+
strategy._track_token(test_token_id, initial_balance)
|
|
379
|
+
assert strategy.tracked_balances.get(test_token_id) == initial_balance
|
|
380
|
+
|
|
381
|
+
strategy._update_balance(test_token_id, updated_balance)
|
|
382
|
+
assert strategy.tracked_balances.get(test_token_id) == updated_balance
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@pytest.mark.asyncio
|
|
386
|
+
async def test_get_non_zero_tracked_tokens(strategy):
|
|
387
|
+
"""Test that _get_non_zero_tracked_tokens returns only non-zero balances."""
|
|
388
|
+
strategy._track_token("token-1", 1000000)
|
|
389
|
+
strategy._track_token("token-2", 0)
|
|
390
|
+
strategy._track_token("token-3", 5000000)
|
|
391
|
+
|
|
392
|
+
non_zero = strategy._get_non_zero_tracked_tokens()
|
|
393
|
+
|
|
394
|
+
assert len(non_zero) == 2
|
|
395
|
+
token_ids = [token_id for token_id, _ in non_zero]
|
|
396
|
+
assert "token-1" in token_ids
|
|
397
|
+
assert "token-3" in token_ids
|
|
398
|
+
assert "token-2" not in token_ids
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@pytest.mark.asyncio
|
|
402
|
+
async def test_refresh_tracked_balances(strategy):
|
|
403
|
+
"""Test that _refresh_tracked_balances updates all tracked token balances."""
|
|
404
|
+
# Track some tokens
|
|
405
|
+
strategy._track_token("usd-coin-base")
|
|
406
|
+
strategy._track_token("ethereum-base")
|
|
407
|
+
|
|
408
|
+
# Refresh balances
|
|
409
|
+
await strategy._refresh_tracked_balances()
|
|
410
|
+
|
|
411
|
+
# Verify balances were fetched
|
|
412
|
+
assert "usd-coin-base" in strategy.tracked_balances
|
|
413
|
+
assert "ethereum-base" in strategy.tracked_balances
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@pytest.mark.asyncio
|
|
417
|
+
async def test_deposit_tracks_usdc(strategy):
|
|
418
|
+
"""Test that deposit operation tracks USDC token."""
|
|
419
|
+
# Clear tracked state
|
|
420
|
+
strategy.tracked_token_ids.clear()
|
|
421
|
+
strategy.tracked_balances.clear()
|
|
422
|
+
|
|
423
|
+
# Perform deposit
|
|
424
|
+
ok, _ = await strategy.deposit(main_token_amount=100.0)
|
|
425
|
+
|
|
426
|
+
# Verify USDC is tracked
|
|
427
|
+
assert ok
|
|
428
|
+
usdc_token_id = strategy.usdc_token_info.get("token_id")
|
|
429
|
+
assert usdc_token_id in strategy.tracked_token_ids
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@pytest.mark.asyncio
|
|
433
|
+
async def test_sweep_wallet_uses_tracked_tokens(strategy):
|
|
434
|
+
"""Test that _sweep_wallet only swaps tracked tokens."""
|
|
435
|
+
# Setup: track some tokens with balances
|
|
436
|
+
strategy._track_token("token-1", 1000000)
|
|
437
|
+
strategy._track_token("token-2", 2000000)
|
|
438
|
+
|
|
439
|
+
# Mock balance adapter to return fresh balances
|
|
440
|
+
strategy.balance_adapter.get_balance = AsyncMock(
|
|
441
|
+
side_effect=lambda token_id, **kwargs: (
|
|
442
|
+
True,
|
|
443
|
+
strategy.tracked_balances.get(token_id, 0),
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Mock brap adapter swap
|
|
448
|
+
strategy.brap_adapter.swap_from_token_ids = AsyncMock(
|
|
449
|
+
return_value=(True, "Swap successful")
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
target_token = {
|
|
453
|
+
"token_id": "usd-coin-base",
|
|
454
|
+
"address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
455
|
+
"chain": {"code": "base", "name": "Base"},
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
# Call sweep
|
|
459
|
+
await strategy._sweep_wallet(target_token)
|
|
460
|
+
|
|
461
|
+
# Verify that swap was called for tracked tokens
|
|
462
|
+
assert strategy.brap_adapter.swap_from_token_ids.called
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@pytest.mark.asyncio
|
|
466
|
+
async def test_get_non_gas_balances_uses_tracked_state(strategy):
|
|
467
|
+
"""Test that _get_non_gas_balances only checks tracked tokens."""
|
|
468
|
+
# Setup tracked tokens
|
|
469
|
+
usdc_token_id = "usd-coin-base"
|
|
470
|
+
pool_token_id = "test-pool-base"
|
|
471
|
+
|
|
472
|
+
strategy._track_token(usdc_token_id, 100000000)
|
|
473
|
+
strategy._track_token(pool_token_id, 50000000000000000000)
|
|
474
|
+
|
|
475
|
+
# Mock refresh
|
|
476
|
+
strategy.balance_adapter.get_balance = AsyncMock(
|
|
477
|
+
side_effect=lambda token_id, **kwargs: (
|
|
478
|
+
True,
|
|
479
|
+
strategy.tracked_balances.get(token_id, 0),
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# Get non-gas balances
|
|
484
|
+
balances = await strategy._get_non_gas_balances()
|
|
485
|
+
|
|
486
|
+
# Verify only tracked tokens are returned (excluding gas)
|
|
487
|
+
token_ids = [b["token_id"] for b in balances]
|
|
488
|
+
assert usdc_token_id in token_ids or pool_token_id in token_ids
|
|
489
|
+
assert len(balances) <= len(strategy.tracked_token_ids)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@pytest.mark.asyncio
|
|
493
|
+
async def test_partial_liquidate_uses_tracked_tokens(strategy):
|
|
494
|
+
"""Test that partial_liquidate only liquidates tracked tokens."""
|
|
495
|
+
# Setup tracked tokens with balances
|
|
496
|
+
strategy._track_token("usd-coin-base", 50000000) # 50 USDC
|
|
497
|
+
strategy._track_token("test-pool-base", 100000000000000000000) # 100 POOL tokens
|
|
498
|
+
|
|
499
|
+
# Mock balance and token adapters
|
|
500
|
+
strategy.balance_adapter.get_balance = AsyncMock(
|
|
501
|
+
side_effect=lambda token_id, **kwargs: (
|
|
502
|
+
True,
|
|
503
|
+
strategy.tracked_balances.get(token_id, 0),
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
strategy.token_adapter.get_token_price = AsyncMock(
|
|
508
|
+
return_value=(True, {"current_price": 1.0})
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
strategy.brap_adapter.swap_from_token_ids = AsyncMock(
|
|
512
|
+
return_value=(True, "Swap successful")
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Call partial liquidate
|
|
516
|
+
ok, msg = await strategy.partial_liquidate(usd_value=75.0)
|
|
517
|
+
|
|
518
|
+
# Verify success
|
|
519
|
+
assert ok
|
|
520
|
+
assert "liquidation completed" in msg.lower()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Adapter Template
|
|
2
|
+
|
|
3
|
+
Adapters expose protocol-specific capabilities to strategies. They should be thin, async wrappers around one or more clients from `wayfinder_paths.core.clients`.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
1. Copy the template:
|
|
8
|
+
```
|
|
9
|
+
cp -r wayfinder_paths/templates/adapter wayfinder_paths/adapters/my_adapter
|
|
10
|
+
```
|
|
11
|
+
2. Rename `MyAdapter` in `adapter.py` and update `manifest.yaml` so the `entrypoint` matches (`adapters.my_adapter.adapter.MyAdapter`).
|
|
12
|
+
3. Declare the capabilities your adapter will provide and list any client dependencies (e.g., `PoolClient`, `LedgerClient`).
|
|
13
|
+
4. Implement the public methods that fulfill those capabilities.
|
|
14
|
+
|
|
15
|
+
## Layout
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
my_adapter/
|
|
19
|
+
├── adapter.py # Adapter implementation
|
|
20
|
+
├── manifest.yaml # Entrypoint + capabilities + dependency list
|
|
21
|
+
├── examples.json # Example payloads (optional but encouraged)
|
|
22
|
+
├── test_adapter.py # Pytest smoke tests
|
|
23
|
+
└── README.md # Adapter-specific notes
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Skeleton adapter
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
|
|
32
|
+
from wayfinder_paths.core.clients.PoolClient import PoolClient
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MyAdapter(BaseAdapter):
|
|
36
|
+
adapter_type = "MY_ADAPTER"
|
|
37
|
+
|
|
38
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
39
|
+
super().__init__("my_adapter", config)
|
|
40
|
+
self.pool_client = PoolClient()
|
|
41
|
+
|
|
42
|
+
async def connect(self) -> bool:
|
|
43
|
+
"""Optional: prime caches / test connectivity."""
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
async def get_pools(self, pool_ids: list[str]) -> tuple[bool, Any]:
|
|
47
|
+
"""Example capability that proxies PoolClient."""
|
|
48
|
+
try:
|
|
49
|
+
data = await self.pool_client.get_pools_by_ids(
|
|
50
|
+
pool_ids=",".join(pool_ids), merge_external=True
|
|
51
|
+
)
|
|
52
|
+
return (True, data)
|
|
53
|
+
except Exception as exc: # noqa: BLE001
|
|
54
|
+
self.logger.error(f"Failed to fetch pools: {exc}")
|
|
55
|
+
return (False, str(exc))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Your adapter should return `(success, payload)` tuples for every operation, just like the built-in adapters do.
|
|
59
|
+
|
|
60
|
+
## Manifest
|
|
61
|
+
|
|
62
|
+
Every adapter needs a manifest describing its import path, declared capabilities, and runtime dependencies.
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
schema_version: "0.1"
|
|
66
|
+
entrypoint: "adapters.my_adapter.adapter.MyAdapter"
|
|
67
|
+
capabilities:
|
|
68
|
+
- "pool.read"
|
|
69
|
+
dependencies:
|
|
70
|
+
- "PoolClient"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The `dependencies` list is informational today but helps reviewers understand which core clients you rely on.
|
|
74
|
+
|
|
75
|
+
## Testing
|
|
76
|
+
|
|
77
|
+
`test_adapter.py` should cover the public methods you expose. Patch out remote clients with `unittest.mock.AsyncMock` so tests run offline.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import pytest
|
|
81
|
+
from unittest.mock import AsyncMock, patch
|
|
82
|
+
|
|
83
|
+
from wayfinder_paths.adapters.my_adapter.adapter import MyAdapter
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_get_pools():
|
|
88
|
+
with patch(
|
|
89
|
+
"wayfinder_paths.adapters.my_adapter.adapter.PoolClient",
|
|
90
|
+
return_value=AsyncMock(
|
|
91
|
+
get_pools_by_ids=AsyncMock(return_value={"pools": []})
|
|
92
|
+
),
|
|
93
|
+
):
|
|
94
|
+
adapter = MyAdapter(config={})
|
|
95
|
+
success, data = await adapter.get_pools(["pool-1"])
|
|
96
|
+
assert success is True
|
|
97
|
+
assert "pools" in data
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Best practices
|
|
101
|
+
|
|
102
|
+
- Capabilities listed in `manifest.yaml` must correspond to methods you implement.
|
|
103
|
+
- Keep adapters stateless and idempotent—strategies may reuse instances across operations.
|
|
104
|
+
- Use `self.logger` for contextual logging (BaseAdapter has already bound the adapter name).
|
|
105
|
+
- Raise `NotImplementedError` for manifest capabilities you intentionally do not support yet.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MyAdapter(BaseAdapter):
|
|
7
|
+
"""
|
|
8
|
+
Template adapter for a protocol/exchange integration.
|
|
9
|
+
Copy this folder, rename it (e.g., my_adapter), update manifest entrypoint,
|
|
10
|
+
and implement the capabilities your manifest declares.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
adapter_type: str = "MY_ADAPTER"
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
16
|
+
super().__init__("my_adapter", config)
|
|
17
|
+
|
|
18
|
+
async def connect(self) -> bool:
|
|
19
|
+
"""Establish connectivity to remote service(s) if needed."""
|
|
20
|
+
return True
|
|
21
|
+
|
|
22
|
+
async def example_operation(self, **kwargs) -> tuple[bool, str]:
|
|
23
|
+
"""
|
|
24
|
+
Example operation. Replace with your adapter's real API.
|
|
25
|
+
"""
|
|
26
|
+
return (True, "example.op executed")
|