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,49 @@
|
|
|
1
|
+
"""Test template for adapters.
|
|
2
|
+
|
|
3
|
+
Quick setup:
|
|
4
|
+
1. Replace MyAdapter with your actual adapter class name
|
|
5
|
+
2. Implement test_basic_functionality with your adapter's core methods
|
|
6
|
+
3. Add client mocking if your adapter uses external clients
|
|
7
|
+
4. Run: pytest wayfinder_paths/adapters/your_adapter/ -v
|
|
8
|
+
|
|
9
|
+
Note: examples.json is optional for adapters (not required).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
# TODO: Replace MyAdapter with your actual adapter class name
|
|
15
|
+
from .adapter import MyAdapter
|
|
16
|
+
|
|
17
|
+
# For mocking clients, uncomment when needed:
|
|
18
|
+
# from unittest.mock import AsyncMock, patch
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestMyAdapter:
|
|
22
|
+
"""Test cases for MyAdapter"""
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def adapter(self):
|
|
26
|
+
"""Create adapter instance for testing."""
|
|
27
|
+
return MyAdapter(config={})
|
|
28
|
+
|
|
29
|
+
@pytest.mark.asyncio
|
|
30
|
+
async def test_health_check(self, adapter):
|
|
31
|
+
"""Test adapter health check"""
|
|
32
|
+
health = await adapter.health_check()
|
|
33
|
+
assert isinstance(health, dict)
|
|
34
|
+
assert health.get("status") in {"healthy", "unhealthy", "error"}
|
|
35
|
+
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
async def test_connect(self, adapter):
|
|
38
|
+
"""Test adapter connection"""
|
|
39
|
+
ok = await adapter.connect()
|
|
40
|
+
assert isinstance(ok, bool)
|
|
41
|
+
|
|
42
|
+
def test_capabilities(self, adapter):
|
|
43
|
+
"""Test adapter capabilities match manifest"""
|
|
44
|
+
assert hasattr(adapter, "adapter_type")
|
|
45
|
+
|
|
46
|
+
@pytest.mark.asyncio
|
|
47
|
+
async def test_basic_functionality(self, adapter):
|
|
48
|
+
"""REQUIRED: Test your adapter's core functionality."""
|
|
49
|
+
assert adapter is not None
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Strategy Template
|
|
2
|
+
|
|
3
|
+
This template provides the scaffolding for a new strategy. It mirrors the structure in `wayfinder_paths/strategies/...` and wires into the runner via a manifest.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
1. Copy the template to a new folder:
|
|
8
|
+
```
|
|
9
|
+
cp -r wayfinder_paths/templates/strategy wayfinder_paths/strategies/my_strategy
|
|
10
|
+
```
|
|
11
|
+
2. Rename the class in `strategy.py` and update `manifest.yaml` so `entrypoint` points to your new module (for example `strategies.my_strategy.strategy.MyStrategy`).
|
|
12
|
+
3. Fill out `examples.json` with sample CLI invocations and `test_strategy.py` with at least one smoke test.
|
|
13
|
+
4. Implement the required strategy methods (`deposit`, `update`, `_status`, optionally override `withdraw`).
|
|
14
|
+
|
|
15
|
+
## Layout
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
my_strategy/
|
|
19
|
+
├── strategy.py # Strategy implementation
|
|
20
|
+
├── manifest.yaml # Entrypoint + adapter requirements + policy
|
|
21
|
+
├── examples.json # Example CLI payloads
|
|
22
|
+
├── test_strategy.py # Pytest-based smoke tests
|
|
23
|
+
└── README.md # Strategy-specific documentation
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Required methods
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
async def deposit(self, main_token_amount: float, gas_token_amount: float) -> StatusTuple:
|
|
30
|
+
"""Move funds from the main wallet into the strategy wallet and prepare on-chain positions."""
|
|
31
|
+
|
|
32
|
+
async def update(self) -> StatusTuple:
|
|
33
|
+
"""Periodic rebalance/update loop."""
|
|
34
|
+
|
|
35
|
+
async def _status(self) -> StatusDict:
|
|
36
|
+
"""Return portfolio_value, net_deposit, and strategy_status payloads."""
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`Strategy.withdraw` already unwinds ledger operations. Override it only if you need custom exit logic.
|
|
40
|
+
|
|
41
|
+
## Wiring adapters
|
|
42
|
+
|
|
43
|
+
Strategies typically:
|
|
44
|
+
|
|
45
|
+
1. Build a `DefaultWeb3Service` so every adapter shares a wallet provider.
|
|
46
|
+
2. Instantiate adapters (balance, ledger, protocol specific, etc.).
|
|
47
|
+
3. Register adapters via `self.register_adapters([...])` and keep references as attributes.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from wayfinder_paths.core.services.web3_service import DefaultWeb3Service
|
|
51
|
+
from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
|
|
52
|
+
from wayfinder_paths.adapters.balance_adapter.adapter import BalanceAdapter
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MyStrategy(Strategy):
|
|
56
|
+
name = "Demo Strategy"
|
|
57
|
+
|
|
58
|
+
def __init__(self, config: dict | None = None):
|
|
59
|
+
super().__init__()
|
|
60
|
+
self.config = config or {}
|
|
61
|
+
web3_service = DefaultWeb3Service(self.config)
|
|
62
|
+
balance_adapter = BalanceAdapter(self.config, web3_service=web3_service)
|
|
63
|
+
self.register_adapters([balance_adapter])
|
|
64
|
+
self.balance_adapter = balance_adapter
|
|
65
|
+
|
|
66
|
+
async def deposit(
|
|
67
|
+
self, main_token_amount: float = 0.0, gas_token_amount: float = 0.0
|
|
68
|
+
) -> StatusTuple:
|
|
69
|
+
"""Perform validation, move funds, and optionally deploy capital."""
|
|
70
|
+
if main_token_amount <= 0:
|
|
71
|
+
return (False, "Nothing to deposit")
|
|
72
|
+
|
|
73
|
+
success, _ = await self.balance_adapter.get_balance(
|
|
74
|
+
token_id=self.config.get("token_id"),
|
|
75
|
+
wallet_address=self.config.get("main_wallet", {}).get("address"),
|
|
76
|
+
)
|
|
77
|
+
if not success:
|
|
78
|
+
return (False, "Unable to fetch balances")
|
|
79
|
+
|
|
80
|
+
# Use BalanceAdapter (which leverages LocalTokenTxnService builders) for transfers.
|
|
81
|
+
self.last_deposit = main_token_amount
|
|
82
|
+
return (True, f"Deposited {main_token_amount} tokens")
|
|
83
|
+
|
|
84
|
+
async def update(self) -> StatusTuple:
|
|
85
|
+
"""Execute your strategy logic periodically."""
|
|
86
|
+
return (True, "No-op update")
|
|
87
|
+
|
|
88
|
+
async def _status(self) -> StatusDict:
|
|
89
|
+
"""Surface state back to run_strategy.py."""
|
|
90
|
+
success, balance = await self.balance_adapter.get_balance(
|
|
91
|
+
token_id=self.config.get("token_id"),
|
|
92
|
+
wallet_address=self.config.get("strategy_wallet", {}).get("address"),
|
|
93
|
+
)
|
|
94
|
+
return {
|
|
95
|
+
"portfolio_value": float(balance or 0),
|
|
96
|
+
"net_deposit": float(getattr(self, "last_deposit", 0.0)),
|
|
97
|
+
"strategy_status": {"message": "healthy" if success else "unknown"},
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Manifest configuration
|
|
102
|
+
|
|
103
|
+
Your `manifest.yaml` should define:
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
schema_version: "0.1"
|
|
107
|
+
entrypoint: "strategies.my_strategy.strategy.MyStrategy"
|
|
108
|
+
permissions:
|
|
109
|
+
policy: "(wallet.id == 'FORMAT_WALLET_ID') && (eth.tx.to == '0x...')"
|
|
110
|
+
adapters:
|
|
111
|
+
- name: "BALANCE"
|
|
112
|
+
capabilities: ["wallet_read", "wallet_transfer"]
|
|
113
|
+
- name: "POOL"
|
|
114
|
+
capabilities: ["pool.read", "pool.analytics"]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Testing
|
|
118
|
+
|
|
119
|
+
`test_strategy.py` should cover at least deposit/update/status. Use `pytest.mark.asyncio` and patch adapters or services as needed.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
import pytest
|
|
123
|
+
from wayfinder_paths.strategies.my_strategy.strategy import MyStrategy
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.asyncio
|
|
127
|
+
async def test_status_shape():
|
|
128
|
+
strat = MyStrategy(config={})
|
|
129
|
+
status = await strat._status()
|
|
130
|
+
assert set(status) == {"portfolio_value", "net_deposit", "strategy_status"}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Running the strategy locally
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# Install dependencies & create wallets first
|
|
137
|
+
poetry install
|
|
138
|
+
# Creates a main wallet (or use 'just create-strategy' which auto-creates wallets)
|
|
139
|
+
poetry run python wayfinder_paths/scripts/make_wallets.py -n 1
|
|
140
|
+
|
|
141
|
+
# Copy config and edit credentials
|
|
142
|
+
cp wayfinder_paths/config.example.json config.json
|
|
143
|
+
|
|
144
|
+
# Run your strategy
|
|
145
|
+
poetry run python wayfinder_paths/run_strategy.py my_strategy --action status --config $(pwd)/config.json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Best practices
|
|
149
|
+
|
|
150
|
+
- Return `(success: bool, message: str)` tuples from `deposit`/`update`.
|
|
151
|
+
- Always populate `portfolio_value`, `net_deposit`, and `strategy_status` keys in `_status`.
|
|
152
|
+
- Use the adapters declared in `manifest.yaml`—they are injected via `register_adapters`.
|
|
153
|
+
- Keep on-chain permissions tight by scoping the manifest policy to the strategy wallet and expected contracts.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
schema_version: "0.1"
|
|
2
|
+
entrypoint: "strategies.my_strategy.strategy.MyStrategy"
|
|
3
|
+
name: "my_strategy" # Unique name for this strategy instance (used for wallet lookup)
|
|
4
|
+
permissions:
|
|
5
|
+
policy: "(wallet.id == 'FORMAT_WALLET_ID')"
|
|
6
|
+
adapters:
|
|
7
|
+
- name: "BALANCE"
|
|
8
|
+
capabilities: ["wallet_read"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MyStrategy(Strategy):
|
|
5
|
+
name = "My Strategy"
|
|
6
|
+
description = "Short description of what the strategy does."
|
|
7
|
+
summary = "One-line summary for discovery."
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
super().__init__()
|
|
11
|
+
|
|
12
|
+
async def setup(self):
|
|
13
|
+
"""Optional initialization logic."""
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
async def deposit(
|
|
17
|
+
self, main_token_amount: float, gas_token_amount: float
|
|
18
|
+
) -> StatusTuple:
|
|
19
|
+
"""Deposit funds into the strategy.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
main_token_amount: Amount of the main token to deposit (e.g., USDC, USDT0)
|
|
23
|
+
gas_token_amount: Amount of gas token to deposit (e.g., ETH, HYPE)
|
|
24
|
+
"""
|
|
25
|
+
return (True, "Deposit successful")
|
|
26
|
+
|
|
27
|
+
async def withdraw(self, amount: float | None = None) -> StatusTuple:
|
|
28
|
+
"""Withdraw funds from the strategy.
|
|
29
|
+
|
|
30
|
+
This method is required. The base Strategy class provides a default
|
|
31
|
+
implementation that unwinds all ledger operations. You can either:
|
|
32
|
+
1. Call the parent implementation (as shown here, recommended for most cases)
|
|
33
|
+
2. Override this method for custom withdrawal logic (e.g., unwinding specific positions,
|
|
34
|
+
converting tokens, handling partial withdrawals)
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
amount: Optional amount to withdraw. If None, withdraws all funds.
|
|
38
|
+
"""
|
|
39
|
+
# Call parent implementation which unwinds all ledger operations
|
|
40
|
+
return await super().withdraw(amount=amount)
|
|
41
|
+
|
|
42
|
+
async def update(self) -> StatusTuple:
|
|
43
|
+
"""Rebalance or update positions."""
|
|
44
|
+
return (True, "Update successful")
|
|
45
|
+
|
|
46
|
+
async def _status(self) -> StatusDict:
|
|
47
|
+
"""Report strategy status."""
|
|
48
|
+
return {
|
|
49
|
+
"portfolio_value": 0.0,
|
|
50
|
+
"net_deposit": 0.0,
|
|
51
|
+
"strategy_status": {},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def policies() -> list[str]:
|
|
56
|
+
"""Return policy strings used to scope on-chain permissions."""
|
|
57
|
+
return []
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Test template for strategies.
|
|
2
|
+
|
|
3
|
+
REQUIRED: This template uses examples.json for all test data.
|
|
4
|
+
Replace 'MyStrategy' with your actual strategy class name.
|
|
5
|
+
|
|
6
|
+
Quick setup:
|
|
7
|
+
1. Replace MyStrategy with your strategy class name
|
|
8
|
+
2. Create examples.json with a 'smoke' example (see TESTING.md)
|
|
9
|
+
3. Add mocking if your strategy uses adapters
|
|
10
|
+
4. Run: pytest wayfinder_paths/strategies/your_strategy/ -v
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# TODO: Replace MyStrategy with your actual strategy class name
|
|
17
|
+
from wayfinder_paths.strategies.your_strategy.strategy import (
|
|
18
|
+
MyStrategy, # noqa: E402
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Ensure wayfinder-paths is on path for tests.test_utils import
|
|
22
|
+
# This is a workaround until conftest loading order is resolved
|
|
23
|
+
_wayfinder_path_dir = Path(__file__).parent.parent.parent.parent.resolve()
|
|
24
|
+
_wayfinder_path_str = str(_wayfinder_path_dir)
|
|
25
|
+
if _wayfinder_path_str not in sys.path:
|
|
26
|
+
sys.path.insert(0, _wayfinder_path_str)
|
|
27
|
+
elif sys.path.index(_wayfinder_path_str) > 0:
|
|
28
|
+
# Move to front to take precedence
|
|
29
|
+
sys.path.remove(_wayfinder_path_str)
|
|
30
|
+
sys.path.insert(0, _wayfinder_path_str)
|
|
31
|
+
|
|
32
|
+
import pytest # noqa: E402
|
|
33
|
+
|
|
34
|
+
# Import test utilities
|
|
35
|
+
try:
|
|
36
|
+
from tests.test_utils import get_canonical_examples, load_strategy_examples
|
|
37
|
+
except ImportError:
|
|
38
|
+
# Fallback if path setup didn't work
|
|
39
|
+
import importlib.util
|
|
40
|
+
|
|
41
|
+
test_utils_path = Path(_wayfinder_path_dir) / "tests" / "test_utils.py"
|
|
42
|
+
spec = importlib.util.spec_from_file_location("tests.test_utils", test_utils_path)
|
|
43
|
+
test_utils = importlib.util.module_from_spec(spec)
|
|
44
|
+
spec.loader.exec_module(test_utils)
|
|
45
|
+
get_canonical_examples = test_utils.get_canonical_examples
|
|
46
|
+
load_strategy_examples = test_utils.load_strategy_examples
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def strategy():
|
|
51
|
+
"""Create a strategy instance for testing with minimal config."""
|
|
52
|
+
mock_config = {
|
|
53
|
+
"main_wallet": {"address": "0x1234567890123456789012345678901234567890"},
|
|
54
|
+
"strategy_wallet": {"address": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
s = MyStrategy(
|
|
58
|
+
config=mock_config,
|
|
59
|
+
main_wallet=mock_config["main_wallet"],
|
|
60
|
+
strategy_wallet=mock_config["strategy_wallet"],
|
|
61
|
+
simulation=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# TODO: Add mocking for your adapters here if needed
|
|
65
|
+
# Example for balance_adapter:
|
|
66
|
+
# if hasattr(s, "balance_adapter") and s.balance_adapter:
|
|
67
|
+
# usdc_balance_mock = AsyncMock(return_value=(True, 60000000))
|
|
68
|
+
# gas_balance_mock = AsyncMock(return_value=(True, 2000000000000000))
|
|
69
|
+
#
|
|
70
|
+
# def get_balance_side_effect(token_id, wallet_address, **kwargs):
|
|
71
|
+
# if token_id == "usd-coin-base" or token_id == "usd-coin":
|
|
72
|
+
# return usdc_balance_mock.return_value
|
|
73
|
+
# elif token_id == "ethereum-base" or token_id == "ethereum":
|
|
74
|
+
# return gas_balance_mock.return_value
|
|
75
|
+
# return (True, 1000000000)
|
|
76
|
+
#
|
|
77
|
+
# s.balance_adapter.get_token_balance_for_wallet = AsyncMock(
|
|
78
|
+
# side_effect=get_balance_side_effect
|
|
79
|
+
# )
|
|
80
|
+
# s.balance_adapter.get_all_enriched_token_balances_for_wallet = AsyncMock(
|
|
81
|
+
# return_value=(True, {"balances": []})
|
|
82
|
+
# )
|
|
83
|
+
|
|
84
|
+
# Example for token_adapter:
|
|
85
|
+
# if hasattr(s, "token_adapter") and s.token_adapter:
|
|
86
|
+
# default_token = {
|
|
87
|
+
# "id": "usd-coin-base",
|
|
88
|
+
# "symbol": "USDC",
|
|
89
|
+
# "name": "USD Coin",
|
|
90
|
+
# "decimals": 6,
|
|
91
|
+
# "address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
92
|
+
# "chain": {"code": "base", "id": 8453, "name": "Base"},
|
|
93
|
+
# }
|
|
94
|
+
# s.token_adapter.get_token = AsyncMock(return_value=(True, default_token))
|
|
95
|
+
# s.token_adapter.get_gas_token = AsyncMock(return_value=(True, default_token))
|
|
96
|
+
|
|
97
|
+
# Example for transaction adapters:
|
|
98
|
+
# if hasattr(s, "tx_adapter") and s.tx_adapter:
|
|
99
|
+
# s.tx_adapter.move_from_main_wallet_to_strategy_wallet = AsyncMock(
|
|
100
|
+
# return_value=(True, "Transfer successful (simulated)")
|
|
101
|
+
# )
|
|
102
|
+
# s.tx_adapter.move_from_strategy_wallet_to_main_wallet = AsyncMock(
|
|
103
|
+
# return_value=(True, "Transfer successful (simulated)")
|
|
104
|
+
# )
|
|
105
|
+
|
|
106
|
+
# Example for ledger_adapter:
|
|
107
|
+
# if hasattr(s, "ledger_adapter") and s.ledger_adapter:
|
|
108
|
+
# s.ledger_adapter.get_strategy_net_deposit = AsyncMock(
|
|
109
|
+
# return_value=(True, {"net_deposit": 0})
|
|
110
|
+
# )
|
|
111
|
+
# s.ledger_adapter.get_strategy_transactions = AsyncMock(
|
|
112
|
+
# return_value=(True, {"transactions": []})
|
|
113
|
+
# )
|
|
114
|
+
|
|
115
|
+
return s
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
@pytest.mark.smoke
|
|
120
|
+
async def test_smoke(strategy):
|
|
121
|
+
"""REQUIRED: Basic smoke test - verifies strategy lifecycle."""
|
|
122
|
+
examples = load_strategy_examples(Path(__file__))
|
|
123
|
+
smoke_data = examples["smoke"]
|
|
124
|
+
|
|
125
|
+
st = await strategy.status()
|
|
126
|
+
assert isinstance(st, dict)
|
|
127
|
+
assert "portfolio_value" in st or "net_deposit" in st or "strategy_status" in st
|
|
128
|
+
|
|
129
|
+
deposit_params = smoke_data.get("deposit", {})
|
|
130
|
+
ok, msg = await strategy.deposit(**deposit_params)
|
|
131
|
+
assert isinstance(ok, bool)
|
|
132
|
+
assert isinstance(msg, str)
|
|
133
|
+
|
|
134
|
+
ok, msg = await strategy.update(**smoke_data.get("update", {}))
|
|
135
|
+
assert isinstance(ok, bool)
|
|
136
|
+
|
|
137
|
+
ok, msg = await strategy.withdraw(**smoke_data.get("withdraw", {}))
|
|
138
|
+
assert isinstance(ok, bool)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_canonical_usage(strategy):
|
|
143
|
+
"""REQUIRED: Test canonical usage examples from examples.json (minimum).
|
|
144
|
+
|
|
145
|
+
Canonical usage = all positive usage examples (excluding error cases).
|
|
146
|
+
This is the MINIMUM requirement - feel free to add more test cases here.
|
|
147
|
+
"""
|
|
148
|
+
examples = load_strategy_examples(Path(__file__))
|
|
149
|
+
canonical = get_canonical_examples(examples)
|
|
150
|
+
|
|
151
|
+
for example_name, example_data in canonical.items():
|
|
152
|
+
if "deposit" in example_data:
|
|
153
|
+
deposit_params = example_data.get("deposit", {})
|
|
154
|
+
ok, _ = await strategy.deposit(**deposit_params)
|
|
155
|
+
assert ok, f"Canonical example '{example_name}' deposit failed"
|
|
156
|
+
|
|
157
|
+
if "update" in example_data:
|
|
158
|
+
ok, msg = await strategy.update()
|
|
159
|
+
assert ok, f"Canonical example '{example_name}' update failed: {msg}"
|
|
160
|
+
|
|
161
|
+
if "status" in example_data:
|
|
162
|
+
st = await strategy.status()
|
|
163
|
+
assert isinstance(st, dict), (
|
|
164
|
+
f"Canonical example '{example_name}' status failed"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@pytest.mark.asyncio
|
|
169
|
+
async def test_error_cases(strategy):
|
|
170
|
+
"""OPTIONAL: Test error scenarios from examples.json."""
|
|
171
|
+
examples = load_strategy_examples(Path(__file__))
|
|
172
|
+
|
|
173
|
+
for example_name, example_data in examples.items():
|
|
174
|
+
if isinstance(example_data, dict) and "expect" in example_data:
|
|
175
|
+
expect = example_data.get("expect", {})
|
|
176
|
+
|
|
177
|
+
if "deposit" in example_data:
|
|
178
|
+
deposit_params = example_data.get("deposit", {})
|
|
179
|
+
ok, _ = await strategy.deposit(**deposit_params)
|
|
180
|
+
|
|
181
|
+
if expect.get("success") is False:
|
|
182
|
+
assert ok is False, (
|
|
183
|
+
f"Expected {example_name} deposit to fail but it succeeded"
|
|
184
|
+
)
|
|
185
|
+
elif expect.get("success") is True:
|
|
186
|
+
assert ok is True, (
|
|
187
|
+
f"Expected {example_name} deposit to succeed but it failed"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if "update" in example_data:
|
|
191
|
+
ok, _ = await strategy.update()
|
|
192
|
+
if "success" in expect:
|
|
193
|
+
expected_success = expect.get("success")
|
|
194
|
+
assert ok == expected_success, (
|
|
195
|
+
f"Expected {example_name} update to "
|
|
196
|
+
f"{'succeed' if expected_success else 'fail'} but got opposite"
|
|
197
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
|
|
4
|
+
from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FakeAdapter(BaseAdapter):
|
|
8
|
+
adapter_type = "FAKE"
|
|
9
|
+
|
|
10
|
+
async def connect(self) -> bool:
|
|
11
|
+
return True
|
|
12
|
+
|
|
13
|
+
async def get_balance(self, asset: str):
|
|
14
|
+
return {"asset": asset, "amount": 100}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeStrategy(Strategy):
|
|
18
|
+
name = "Fake Strategy"
|
|
19
|
+
|
|
20
|
+
async def deposit(self, amount: float = 0) -> StatusTuple:
|
|
21
|
+
return (True, "deposited")
|
|
22
|
+
|
|
23
|
+
async def update(self) -> StatusTuple:
|
|
24
|
+
return (True, "updated")
|
|
25
|
+
|
|
26
|
+
async def withdraw(self, amount: float = 0) -> StatusTuple:
|
|
27
|
+
return (True, "withdrew")
|
|
28
|
+
|
|
29
|
+
async def status(self) -> StatusDict:
|
|
30
|
+
return {"total_earned": 0.0, "strategy_status": {"ok": True}}
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def policy() -> str:
|
|
34
|
+
return "wallet.id == 'TEST'"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_smoke_deposit_update_withdraw_status():
|
|
39
|
+
s = FakeStrategy()
|
|
40
|
+
s.register_adapters([FakeAdapter("fake")])
|
|
41
|
+
ok, msg = await s.deposit(amount=1)
|
|
42
|
+
assert ok
|
|
43
|
+
ok, msg = await s.update()
|
|
44
|
+
assert ok
|
|
45
|
+
ok, msg = await s.withdraw(amount=1)
|
|
46
|
+
assert ok
|
|
47
|
+
st = await s.status()
|
|
48
|
+
assert "total_earned" in st
|