wayfinder-paths 0.1.29__py3-none-any.whl → 0.1.31__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/adapters/boros_adapter/adapter.py +313 -12
- wayfinder_paths/adapters/boros_adapter/test_adapter.py +125 -14
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +17 -3
- wayfinder_paths/adapters/hyperliquid_adapter/exchange.py +5 -5
- wayfinder_paths/adapters/hyperliquid_adapter/local_signer.py +2 -33
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1 -1
- wayfinder_paths/adapters/hyperliquid_adapter/test_cancel_order.py +57 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_exchange_mid_prices.py +52 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_hyperliquid_sdk_live.py +64 -0
- wayfinder_paths/adapters/hyperliquid_adapter/util.py +9 -10
- wayfinder_paths/core/clients/PoolClient.py +1 -1
- wayfinder_paths/core/constants/hype_oft_abi.py +151 -0
- wayfinder_paths/core/strategies/Strategy.py +1 -2
- wayfinder_paths/mcp/tools/execute.py +48 -16
- wayfinder_paths/mcp/tools/hyperliquid.py +1 -13
- wayfinder_paths/mcp/tools/quotes.py +38 -124
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +24 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +249 -29
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +125 -15
- wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +57 -201
- wayfinder_paths/strategies/boros_hype_strategy/constants.py +1 -152
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +29 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/manifest.yaml +33 -0
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +23 -0
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +2 -0
- wayfinder_paths/tests/test_manifests.py +93 -0
- wayfinder_paths/tests/test_mcp_balances.py +73 -0
- wayfinder_paths/tests/test_mcp_discovery.py +34 -0
- wayfinder_paths/tests/test_mcp_execute.py +146 -0
- wayfinder_paths/tests/test_mcp_hyperliquid_execute.py +69 -0
- wayfinder_paths/tests/test_mcp_idempotency_store.py +14 -0
- wayfinder_paths/tests/test_mcp_quote_swap.py +60 -0
- wayfinder_paths/tests/test_mcp_run_script.py +47 -0
- wayfinder_paths/tests/test_mcp_tokens.py +49 -0
- wayfinder_paths/tests/test_mcp_utils.py +35 -0
- wayfinder_paths/tests/test_mcp_wallets.py +38 -0
- {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/METADATA +2 -2
- {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/RECORD +42 -25
- {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/WHEEL +1 -1
- wayfinder_paths/core/types.py +0 -19
- {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/LICENSE +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import re
|
|
4
3
|
from typing import Any
|
|
5
4
|
|
|
6
5
|
from wayfinder_paths.core.clients.BRAPClient import BRAPClient
|
|
7
6
|
from wayfinder_paths.core.clients.TokenClient import TokenClient
|
|
8
|
-
from wayfinder_paths.
|
|
7
|
+
from wayfinder_paths.mcp.tools.execute import (
|
|
8
|
+
_resolve_token_meta,
|
|
9
|
+
_select_token_chain,
|
|
10
|
+
)
|
|
9
11
|
from wayfinder_paths.mcp.utils import (
|
|
10
12
|
err,
|
|
11
13
|
find_wallet_by_label,
|
|
@@ -15,131 +17,49 @@ from wayfinder_paths.mcp.utils import (
|
|
|
15
17
|
)
|
|
16
18
|
|
|
17
19
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
if token.get("chain_id") is not None:
|
|
21
|
-
try:
|
|
22
|
-
return int(token.get("chain_id"))
|
|
23
|
-
except (TypeError, ValueError):
|
|
24
|
-
return None
|
|
25
|
-
|
|
26
|
-
chain = token.get("chain") or {}
|
|
27
|
-
if not isinstance(chain, dict):
|
|
28
|
-
return None
|
|
29
|
-
|
|
30
|
-
for key in ("chain_id", "chainId", "id"):
|
|
31
|
-
if chain.get(key) is None:
|
|
32
|
-
continue
|
|
33
|
-
try:
|
|
34
|
-
return int(chain.get(key))
|
|
35
|
-
except (TypeError, ValueError):
|
|
36
|
-
return None
|
|
37
|
-
|
|
38
|
-
return None
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
_SIMPLE_CHAIN_SUFFIX_RE = re.compile(r"^[a-z0-9]+\s+[a-z0-9-]+$", re.IGNORECASE)
|
|
42
|
-
_ASSET_CHAIN_SPLIT_RE = re.compile(
|
|
43
|
-
r"^(?P<asset>[a-z0-9]+)[- _](?P<chain>[a-z0-9-]+)$", re.IGNORECASE
|
|
44
|
-
)
|
|
20
|
+
def _slippage_float(slippage_bps: int) -> float:
|
|
21
|
+
return max(0.0, float(int(slippage_bps)) / 10_000.0)
|
|
45
22
|
|
|
46
23
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return q
|
|
53
|
-
asset, chain_code = q.rsplit(" ", 1)
|
|
54
|
-
if chain_code.lower() in CHAIN_CODE_TO_ID:
|
|
55
|
-
return f"{asset}-{chain_code}"
|
|
56
|
-
return q
|
|
24
|
+
def _unwrap_brap_quote_response(
|
|
25
|
+
data: Any,
|
|
26
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any] | None, int]:
|
|
27
|
+
"""
|
|
28
|
+
BRAP quote responses have historically appeared in two shapes:
|
|
57
29
|
|
|
30
|
+
1) {"quotes": [...], "best_quote": {...}}
|
|
31
|
+
2) {"quotes": {"all_quotes": [...], "best_quote": {...}, "quote_count": N}}
|
|
58
32
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
33
|
+
This helper normalizes both to (all_quotes, best_quote, quote_count).
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(data, dict):
|
|
36
|
+
return [], None, 0
|
|
63
37
|
|
|
38
|
+
raw_quotes = data.get("quotes")
|
|
39
|
+
best_quote = data.get("best_quote")
|
|
64
40
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return
|
|
69
|
-
m = _ASSET_CHAIN_SPLIT_RE.match(q)
|
|
70
|
-
if not m:
|
|
71
|
-
return None
|
|
72
|
-
return m.group("asset").lower(), m.group("chain").lower()
|
|
41
|
+
if isinstance(raw_quotes, list) or isinstance(best_quote, dict):
|
|
42
|
+
all_quotes = raw_quotes if isinstance(raw_quotes, list) else []
|
|
43
|
+
best = best_quote if isinstance(best_quote, dict) else None
|
|
44
|
+
return all_quotes, best, len(all_quotes)
|
|
73
45
|
|
|
46
|
+
# Legacy/nested payload under `quotes`
|
|
47
|
+
if isinstance(raw_quotes, dict):
|
|
48
|
+
all_quotes = raw_quotes.get("all_quotes") or raw_quotes.get("quotes") or []
|
|
49
|
+
if not isinstance(all_quotes, list):
|
|
50
|
+
all_quotes = []
|
|
51
|
+
best = raw_quotes.get("best_quote")
|
|
52
|
+
best_out = best if isinstance(best, dict) else None
|
|
74
53
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
q = _normalize_token_query(query)
|
|
81
|
-
split = _split_asset_chain(q)
|
|
82
|
-
if split:
|
|
83
|
-
asset, chain_code = split
|
|
84
|
-
if asset in {"eth", "ethereum"}:
|
|
85
|
-
try:
|
|
86
|
-
gas_meta = await token_client.get_gas_token(chain_code)
|
|
87
|
-
if isinstance(gas_meta, dict) and _is_eth_like_token(gas_meta):
|
|
88
|
-
return q, gas_meta
|
|
89
|
-
except Exception:
|
|
90
|
-
pass
|
|
91
|
-
meta = await token_client.get_token_details(q)
|
|
92
|
-
return q, meta
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _infer_chain_code_from_query(query: str, meta: dict[str, Any]) -> str | None:
|
|
96
|
-
q = str(query).strip().lower()
|
|
97
|
-
if not q:
|
|
98
|
-
return None
|
|
99
|
-
|
|
100
|
-
candidates: set[str] = {str(k).lower() for k in CHAIN_CODE_TO_ID.keys()}
|
|
101
|
-
addrs = meta.get("addresses") or {}
|
|
102
|
-
if isinstance(addrs, dict):
|
|
103
|
-
candidates.update(str(k).lower() for k in addrs.keys())
|
|
104
|
-
|
|
105
|
-
best: str | None = None
|
|
106
|
-
for code in candidates:
|
|
107
|
-
if q.endswith(f"-{code}"):
|
|
108
|
-
if best is None or len(code) > len(best):
|
|
109
|
-
best = code
|
|
110
|
-
return best
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _address_for_chain(meta: dict[str, Any], chain_code: str) -> str | None:
|
|
114
|
-
addrs = meta.get("addresses") or {}
|
|
115
|
-
if not isinstance(addrs, dict):
|
|
116
|
-
return None
|
|
117
|
-
for key, val in addrs.items():
|
|
118
|
-
if str(key).lower() == chain_code and val:
|
|
119
|
-
return str(val)
|
|
120
|
-
return None
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _select_token_chain(
|
|
124
|
-
meta: dict[str, Any], *, query: str
|
|
125
|
-
) -> tuple[int | None, str | None]:
|
|
126
|
-
chain_id = _chain_id(meta)
|
|
127
|
-
token_address = meta.get("address")
|
|
128
|
-
|
|
129
|
-
desired_chain = _infer_chain_code_from_query(query, meta)
|
|
130
|
-
if desired_chain:
|
|
131
|
-
addr = _address_for_chain(meta, desired_chain)
|
|
132
|
-
if addr:
|
|
133
|
-
token_address = addr
|
|
134
|
-
if desired_chain in CHAIN_CODE_TO_ID:
|
|
135
|
-
chain_id = CHAIN_CODE_TO_ID[desired_chain]
|
|
136
|
-
|
|
137
|
-
token_address_out = str(token_address).strip() if token_address else None
|
|
138
|
-
return chain_id, token_address_out
|
|
54
|
+
quote_count = raw_quotes.get("quote_count")
|
|
55
|
+
try:
|
|
56
|
+
quote_count_i = int(quote_count)
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
quote_count_i = len(all_quotes)
|
|
139
59
|
|
|
60
|
+
return all_quotes, best_out, quote_count_i
|
|
140
61
|
|
|
141
|
-
|
|
142
|
-
return max(0.0, float(int(slippage_bps)) / 10_000.0)
|
|
62
|
+
return [], None, 0
|
|
143
63
|
|
|
144
64
|
|
|
145
65
|
async def quote_swap(
|
|
@@ -205,13 +125,7 @@ async def quote_swap(
|
|
|
205
125
|
except Exception as exc: # noqa: BLE001
|
|
206
126
|
return err("quote_error", str(exc))
|
|
207
127
|
|
|
208
|
-
|
|
209
|
-
raw_quotes = data.get("quotes", []) if isinstance(data, dict) else []
|
|
210
|
-
if not isinstance(raw_quotes, list):
|
|
211
|
-
raw_quotes = []
|
|
212
|
-
all_quotes = raw_quotes
|
|
213
|
-
best_quote = data.get("best_quote") if isinstance(data, dict) else None
|
|
214
|
-
quote_count = len(all_quotes)
|
|
128
|
+
all_quotes, best_quote, quote_count = _unwrap_brap_quote_response(data)
|
|
215
129
|
|
|
216
130
|
providers: list[str] = []
|
|
217
131
|
seen: set[str] = set()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
schema_version: "0.1"
|
|
2
|
+
entrypoint: "wayfinder_paths.strategies.basis_trading_strategy.strategy.BasisTradingStrategy"
|
|
3
|
+
permissions:
|
|
4
|
+
policy: |
|
|
5
|
+
(wallet.id == 'FORMAT_WALLET_ID') AND (
|
|
6
|
+
# Allow Hyperliquid EIP-712 order/transfer actions
|
|
7
|
+
(action.type == 'hyperliquid_order') OR
|
|
8
|
+
(action.type == 'hyperliquid_cancel') OR
|
|
9
|
+
(action.type == 'hyperliquid_transfer') OR
|
|
10
|
+
(action.type == 'hyperliquid_withdraw') OR
|
|
11
|
+
# Allow USDC transfers to Hyperliquid bridge
|
|
12
|
+
(action.type == 'erc20_transfer' AND action.to == '0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7') OR
|
|
13
|
+
# Allow withdrawals to main wallet
|
|
14
|
+
(action.type == 'erc20_transfer' AND action.to == main_wallet.address)
|
|
15
|
+
)
|
|
16
|
+
adapters:
|
|
17
|
+
- name: "BALANCE"
|
|
18
|
+
capabilities: ["wallet_read", "wallet_transfer"]
|
|
19
|
+
- name: "LEDGER"
|
|
20
|
+
capabilities: ["ledger.read", "ledger.write", "strategy.transactions"]
|
|
21
|
+
- name: "TOKEN"
|
|
22
|
+
capabilities: ["token.read"]
|
|
23
|
+
- name: "HYPERLIQUID"
|
|
24
|
+
capabilities: ["market.read", "market.meta", "market.funding", "market.candles", "market.orderbook", "order.execute", "order.cancel", "position.manage", "transfer", "withdraw"]
|
|
@@ -58,7 +58,6 @@ from wayfinder_paths.core.strategies.descriptors import (
|
|
|
58
58
|
Volatility,
|
|
59
59
|
)
|
|
60
60
|
from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
|
|
61
|
-
from wayfinder_paths.core.types import HyperliquidSignCallback
|
|
62
61
|
from wayfinder_paths.policies.erc20 import any_erc20_function
|
|
63
62
|
from wayfinder_paths.policies.hyperliquid import (
|
|
64
63
|
any_hyperliquid_l1_payload,
|
|
@@ -182,7 +181,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
182
181
|
*,
|
|
183
182
|
main_wallet: dict[str, Any] | None = None,
|
|
184
183
|
strategy_wallet: dict[str, Any] | None = None,
|
|
185
|
-
strategy_sign_typed_data:
|
|
184
|
+
strategy_sign_typed_data: Callable[[dict], Awaitable[str]] | None = None,
|
|
186
185
|
main_wallet_signing_callback: Callable[[dict], Awaitable[str]] | None = None,
|
|
187
186
|
strategy_wallet_signing_callback: Callable[[dict], Awaitable[str]]
|
|
188
187
|
| None = None,
|
|
@@ -516,11 +515,14 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
516
515
|
f"Found ${strategy_usdc:.2f} USDC in strategy wallet, bridging to Hyperliquid"
|
|
517
516
|
)
|
|
518
517
|
|
|
518
|
+
# Convert USDC to raw units (6 decimals)
|
|
519
|
+
usdc_raw = int(strategy_usdc * 1e6)
|
|
519
520
|
success, result = await self.balance_adapter.send_to_address(
|
|
520
521
|
token_id=USDC_ARBITRUM_TOKEN_ID,
|
|
521
|
-
amount=
|
|
522
|
+
amount=usdc_raw,
|
|
522
523
|
from_wallet=strategy_wallet,
|
|
523
524
|
to_address=HYPERLIQUID_BRIDGE,
|
|
525
|
+
signing_callback=self.strategy_wallet_signing_callback,
|
|
524
526
|
)
|
|
525
527
|
|
|
526
528
|
if not success:
|
|
@@ -560,7 +562,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
560
562
|
self.logger.warning(f"Failed to bridge USDC to Hyperliquid: {e}")
|
|
561
563
|
|
|
562
564
|
if self.current_position is None:
|
|
563
|
-
return await self._find_and_open_position()
|
|
565
|
+
return await self._find_and_open_position(rotation_reason="initial_open")
|
|
564
566
|
|
|
565
567
|
# Monitor existing position (handles idle capital, leg balance, stop-loss)
|
|
566
568
|
return await self._monitor_position()
|
|
@@ -1030,7 +1032,9 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1030
1032
|
# Position Management #
|
|
1031
1033
|
# ------------------------------------------------------------------ #
|
|
1032
1034
|
|
|
1033
|
-
async def _find_and_open_position(
|
|
1035
|
+
async def _find_and_open_position(
|
|
1036
|
+
self, *, rotation_reason: str | None = None
|
|
1037
|
+
) -> StatusTuple:
|
|
1034
1038
|
self.logger.info("Analyzing basis trading opportunities...")
|
|
1035
1039
|
|
|
1036
1040
|
try:
|
|
@@ -1256,6 +1260,19 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1256
1260
|
entry_timestamp=int(time.time() * 1000),
|
|
1257
1261
|
)
|
|
1258
1262
|
|
|
1263
|
+
try:
|
|
1264
|
+
await self._record_rotation(
|
|
1265
|
+
coin=coin,
|
|
1266
|
+
spot_asset_id=int(spot_asset_id),
|
|
1267
|
+
perp_asset_id=int(perp_asset_id),
|
|
1268
|
+
spot_units=float(spot_filled),
|
|
1269
|
+
perp_units=float(perp_filled),
|
|
1270
|
+
leverage=int(leverage),
|
|
1271
|
+
reason=rotation_reason,
|
|
1272
|
+
)
|
|
1273
|
+
except Exception as exc: # noqa: BLE001
|
|
1274
|
+
self.logger.debug(f"Failed to record rotation: {exc}")
|
|
1275
|
+
|
|
1259
1276
|
return (
|
|
1260
1277
|
True,
|
|
1261
1278
|
f"Opened basis position on {coin}: {spot_filled:.4f} units at {leverage}x, expected net APY: {expected_net_apy_pct:.1f}%",
|
|
@@ -1269,6 +1286,128 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1269
1286
|
# Position Scaling #
|
|
1270
1287
|
# ------------------------------------------------------------------ #
|
|
1271
1288
|
|
|
1289
|
+
async def _unused_usd_now(
|
|
1290
|
+
self,
|
|
1291
|
+
state: dict[str, Any],
|
|
1292
|
+
*,
|
|
1293
|
+
mid_prices: dict[str, Any] | None = None,
|
|
1294
|
+
) -> tuple[float, float]:
|
|
1295
|
+
"""Estimate deployable idle USDC without mistaking perp PnL for new cash.
|
|
1296
|
+
|
|
1297
|
+
`marginSummary.withdrawable` can increase just because the perp leg moves in our favor
|
|
1298
|
+
(unrealized PnL), which frees margin but does not represent a fresh cash deposit.
|
|
1299
|
+
This helper estimates idle capital by comparing current bankroll to what's allocated
|
|
1300
|
+
to the delta-neutral position.
|
|
1301
|
+
|
|
1302
|
+
Returns:
|
|
1303
|
+
- unused_usd: estimated deployable idle USDC.
|
|
1304
|
+
- bankroll_now: estimated bankroll backing the position (perp equity + spot value + spot USDC).
|
|
1305
|
+
"""
|
|
1306
|
+
if self.current_position is None:
|
|
1307
|
+
return 0.0, 0.0
|
|
1308
|
+
|
|
1309
|
+
coin = self.current_position.coin
|
|
1310
|
+
|
|
1311
|
+
perp_position = self._get_perp_position(state)
|
|
1312
|
+
if perp_position is None:
|
|
1313
|
+
return 0.0, 0.0
|
|
1314
|
+
|
|
1315
|
+
if mid_prices is None:
|
|
1316
|
+
success, mids = await self.hyperliquid_adapter.get_all_mid_prices()
|
|
1317
|
+
if not success:
|
|
1318
|
+
return 0.0, 0.0
|
|
1319
|
+
mid_prices = mids
|
|
1320
|
+
|
|
1321
|
+
current_price = float(self._resolve_mid_price(coin, mid_prices) or 0.0)
|
|
1322
|
+
if current_price <= 0:
|
|
1323
|
+
return 0.0, 0.0
|
|
1324
|
+
|
|
1325
|
+
# Spot side: only track the strategy coin + USDC. (Other balances are ignored for safety.)
|
|
1326
|
+
address = self._get_strategy_wallet_address()
|
|
1327
|
+
spot_units = 0.0
|
|
1328
|
+
spot_usdc = 0.0
|
|
1329
|
+
success, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
|
|
1330
|
+
address
|
|
1331
|
+
)
|
|
1332
|
+
if success:
|
|
1333
|
+
for bal in spot_state.get("balances", []):
|
|
1334
|
+
bal_coin = str(bal.get("coin", ""))
|
|
1335
|
+
try:
|
|
1336
|
+
total = float(bal.get("total", 0) or 0.0)
|
|
1337
|
+
except (TypeError, ValueError):
|
|
1338
|
+
continue
|
|
1339
|
+
|
|
1340
|
+
if bal_coin == "USDC":
|
|
1341
|
+
spot_usdc = total
|
|
1342
|
+
elif self._coins_match(bal_coin, coin):
|
|
1343
|
+
spot_units = abs(total)
|
|
1344
|
+
|
|
1345
|
+
spot_value = spot_units * current_price
|
|
1346
|
+
|
|
1347
|
+
# Perp side (account value includes margin + unrealized PnL + funding).
|
|
1348
|
+
margin_summary = state.get("marginSummary") or {}
|
|
1349
|
+
try:
|
|
1350
|
+
perp_equity = float(margin_summary.get("accountValue", 0) or 0.0)
|
|
1351
|
+
except (TypeError, ValueError):
|
|
1352
|
+
perp_equity = 0.0
|
|
1353
|
+
|
|
1354
|
+
bankroll_now = perp_equity + spot_value + spot_usdc
|
|
1355
|
+
|
|
1356
|
+
# Position sizing inputs.
|
|
1357
|
+
try:
|
|
1358
|
+
perp_units = abs(float(perp_position.get("szi", 0) or 0.0))
|
|
1359
|
+
except (TypeError, ValueError):
|
|
1360
|
+
perp_units = 0.0
|
|
1361
|
+
|
|
1362
|
+
if perp_units <= 0 or spot_units <= 0:
|
|
1363
|
+
return 0.0, bankroll_now
|
|
1364
|
+
|
|
1365
|
+
entry_px_raw = perp_position.get("entryPx") or self.current_position.entry_price
|
|
1366
|
+
try:
|
|
1367
|
+
perp_entry_price = float(entry_px_raw or 0.0)
|
|
1368
|
+
except (TypeError, ValueError):
|
|
1369
|
+
perp_entry_price = 0.0
|
|
1370
|
+
|
|
1371
|
+
if perp_entry_price <= 0:
|
|
1372
|
+
return 0.0, bankroll_now
|
|
1373
|
+
|
|
1374
|
+
leverage = float(
|
|
1375
|
+
self.current_position.leverage or self.DEFAULT_MAX_LEVERAGE or 1
|
|
1376
|
+
)
|
|
1377
|
+
lev_raw = perp_position.get("leverage")
|
|
1378
|
+
if isinstance(lev_raw, dict):
|
|
1379
|
+
try:
|
|
1380
|
+
leverage = float(lev_raw.get("value") or leverage)
|
|
1381
|
+
except (TypeError, ValueError):
|
|
1382
|
+
pass
|
|
1383
|
+
elif lev_raw is not None:
|
|
1384
|
+
try:
|
|
1385
|
+
leverage = float(lev_raw)
|
|
1386
|
+
except (TypeError, ValueError):
|
|
1387
|
+
pass
|
|
1388
|
+
if leverage <= 0:
|
|
1389
|
+
leverage = 1.0
|
|
1390
|
+
|
|
1391
|
+
funding_since_open = 0.0
|
|
1392
|
+
cum_funding = perp_position.get("cumFunding") or {}
|
|
1393
|
+
if isinstance(cum_funding, dict):
|
|
1394
|
+
try:
|
|
1395
|
+
funding_since_open = abs(float(cum_funding.get("sinceOpen", 0) or 0.0))
|
|
1396
|
+
except (TypeError, ValueError):
|
|
1397
|
+
funding_since_open = 0.0
|
|
1398
|
+
|
|
1399
|
+
# Margin+PnL contribution of the perp leg for a short at leverage L.
|
|
1400
|
+
current_perp_contrib = (
|
|
1401
|
+
perp_entry_price * (1.0 + 1.0 / leverage) - current_price
|
|
1402
|
+
) * perp_units
|
|
1403
|
+
# Conservative guard: don't let extreme loss create fake "unused" capital.
|
|
1404
|
+
current_perp_contrib = max(0.0, current_perp_contrib)
|
|
1405
|
+
|
|
1406
|
+
allocated_now = current_perp_contrib + spot_value + funding_since_open
|
|
1407
|
+
unused_usd = max(0.0, bankroll_now - allocated_now)
|
|
1408
|
+
|
|
1409
|
+
return unused_usd, bankroll_now
|
|
1410
|
+
|
|
1272
1411
|
async def _get_undeployed_capital(self) -> tuple[float, float]:
|
|
1273
1412
|
address = self._get_strategy_wallet_address()
|
|
1274
1413
|
|
|
@@ -1496,7 +1635,9 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1496
1635
|
False,
|
|
1497
1636
|
f"Emergency rebalance failed - could not close: {close_msg}",
|
|
1498
1637
|
)
|
|
1499
|
-
return await self._find_and_open_position(
|
|
1638
|
+
return await self._find_and_open_position(
|
|
1639
|
+
rotation_reason="near_liquidation"
|
|
1640
|
+
)
|
|
1500
1641
|
|
|
1501
1642
|
needs_rebalance, reason = await self._needs_new_position(state, hl_value)
|
|
1502
1643
|
|
|
@@ -1515,7 +1656,7 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1515
1656
|
if not close_success:
|
|
1516
1657
|
return (False, f"Rebalance failed - could not close: {close_msg}")
|
|
1517
1658
|
|
|
1518
|
-
return await self._find_and_open_position()
|
|
1659
|
+
return await self._find_and_open_position(rotation_reason=str(reason))
|
|
1519
1660
|
|
|
1520
1661
|
leg_ok, leg_msg = await self._verify_leg_balance(state)
|
|
1521
1662
|
if not leg_ok:
|
|
@@ -1526,15 +1667,14 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1526
1667
|
else:
|
|
1527
1668
|
actions_taken.append(f"Leg imbalance repair failed: {repair_msg}")
|
|
1528
1669
|
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
min_deploy = max(self.MIN_UNUSED_USD, self.UNUSED_REL_EPS * self.deposit_amount)
|
|
1670
|
+
unused_usd, bankroll_now = await self._unused_usd_now(state)
|
|
1671
|
+
min_deploy = max(self.MIN_UNUSED_USD, self.UNUSED_REL_EPS * bankroll_now)
|
|
1532
1672
|
|
|
1533
|
-
if
|
|
1673
|
+
if unused_usd > min_deploy:
|
|
1534
1674
|
self.logger.info(
|
|
1535
|
-
f"Found ${
|
|
1675
|
+
f"Found ${unused_usd:.2f} deployable idle USDC, scaling up position"
|
|
1536
1676
|
)
|
|
1537
|
-
scale_ok, scale_msg = await self._scale_up_position(
|
|
1677
|
+
scale_ok, scale_msg = await self._scale_up_position(unused_usd)
|
|
1538
1678
|
if scale_ok:
|
|
1539
1679
|
actions_taken.append(f"Scaled up: {scale_msg}")
|
|
1540
1680
|
# Refresh state after scale-up so stop-loss uses new position size/liq price
|
|
@@ -1551,16 +1691,23 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1551
1691
|
actions_taken.append(sl_msg)
|
|
1552
1692
|
|
|
1553
1693
|
position_age_hours = (time.time() * 1000 - pos.entry_timestamp) / (1000 * 3600)
|
|
1694
|
+
rotation_hint = ""
|
|
1695
|
+
try:
|
|
1696
|
+
rotation_hint = await self._rotation_cooldown_hint()
|
|
1697
|
+
except Exception as exc: # noqa: BLE001
|
|
1698
|
+
self.logger.debug(f"Could not compute rotation cooldown hint: {exc}")
|
|
1554
1699
|
|
|
1555
1700
|
if actions_taken:
|
|
1701
|
+
suffix = f". Rotation: {rotation_hint}" if rotation_hint else ""
|
|
1556
1702
|
return (
|
|
1557
1703
|
True,
|
|
1558
|
-
f"Position on {coin} monitored, age: {position_age_hours:.1f}h. Actions: {'; '.join(actions_taken)}",
|
|
1704
|
+
f"Position on {coin} monitored, age: {position_age_hours:.1f}h. Actions: {'; '.join(actions_taken)}{suffix}",
|
|
1559
1705
|
)
|
|
1560
1706
|
|
|
1707
|
+
suffix = f". Rotation: {rotation_hint}" if rotation_hint else ""
|
|
1561
1708
|
return (
|
|
1562
1709
|
True,
|
|
1563
|
-
f"Position on {coin} healthy, age: {position_age_hours:.1f}h",
|
|
1710
|
+
f"Position on {coin} healthy, age: {position_age_hours:.1f}h{suffix}",
|
|
1564
1711
|
)
|
|
1565
1712
|
|
|
1566
1713
|
async def _is_near_liquidation(self, state: dict[str, Any]) -> tuple[bool, str]:
|
|
@@ -2129,6 +2276,44 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2129
2276
|
# Rotation Cooldown #
|
|
2130
2277
|
# ------------------------------------------------------------------ #
|
|
2131
2278
|
|
|
2279
|
+
async def _record_rotation(
|
|
2280
|
+
self,
|
|
2281
|
+
*,
|
|
2282
|
+
coin: str,
|
|
2283
|
+
spot_asset_id: int,
|
|
2284
|
+
perp_asset_id: int,
|
|
2285
|
+
spot_units: float,
|
|
2286
|
+
perp_units: float,
|
|
2287
|
+
leverage: int,
|
|
2288
|
+
reason: str | None = None,
|
|
2289
|
+
) -> None:
|
|
2290
|
+
"""Write a rotation marker to the local ledger so cooldown survives restarts."""
|
|
2291
|
+
if not self.ledger_adapter:
|
|
2292
|
+
return
|
|
2293
|
+
|
|
2294
|
+
wallet_address = self._get_strategy_wallet_address()
|
|
2295
|
+
op_data: dict[str, Any] = {
|
|
2296
|
+
"type": "BASIS_ROTATION",
|
|
2297
|
+
"coin": str(coin),
|
|
2298
|
+
"spot_asset_id": int(spot_asset_id),
|
|
2299
|
+
"perp_asset_id": int(perp_asset_id),
|
|
2300
|
+
"spot_units": float(spot_units),
|
|
2301
|
+
"perp_units": float(perp_units),
|
|
2302
|
+
"leverage": int(leverage),
|
|
2303
|
+
}
|
|
2304
|
+
if reason:
|
|
2305
|
+
op_data["reason"] = str(reason)
|
|
2306
|
+
|
|
2307
|
+
try:
|
|
2308
|
+
await self.ledger_adapter.ledger_client.add_strategy_operation(
|
|
2309
|
+
wallet_address=wallet_address,
|
|
2310
|
+
operation_data=op_data,
|
|
2311
|
+
usd_value="0",
|
|
2312
|
+
strategy_name=self.name or "basis_trading_strategy",
|
|
2313
|
+
)
|
|
2314
|
+
except Exception as exc: # noqa: BLE001
|
|
2315
|
+
self.logger.debug(f"Failed to write BASIS_ROTATION to ledger: {exc}")
|
|
2316
|
+
|
|
2132
2317
|
async def _get_last_rotation_time(self) -> datetime | None:
|
|
2133
2318
|
wallet_address = self._get_strategy_wallet_address()
|
|
2134
2319
|
|
|
@@ -2146,16 +2331,20 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2146
2331
|
else []
|
|
2147
2332
|
)
|
|
2148
2333
|
for txn in tx_list:
|
|
2149
|
-
op_data = txn.get("op_data"
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2334
|
+
op_data = txn.get("op_data") or {}
|
|
2335
|
+
op_type = str(
|
|
2336
|
+
txn.get("operation")
|
|
2337
|
+
or (op_data.get("type") if isinstance(op_data, dict) else "")
|
|
2338
|
+
or ""
|
|
2339
|
+
)
|
|
2340
|
+
if op_type != "BASIS_ROTATION":
|
|
2341
|
+
continue
|
|
2342
|
+
|
|
2343
|
+
created_str = txn.get("created") or txn.get("timestamp")
|
|
2344
|
+
if created_str:
|
|
2345
|
+
return datetime.fromisoformat(
|
|
2346
|
+
str(created_str).replace("Z", "+00:00")
|
|
2347
|
+
)
|
|
2159
2348
|
|
|
2160
2349
|
return None
|
|
2161
2350
|
except Exception as e:
|
|
@@ -2175,14 +2364,45 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
2175
2364
|
if last_rotation.tzinfo is None:
|
|
2176
2365
|
last_rotation = last_rotation.replace(tzinfo=UTC)
|
|
2177
2366
|
|
|
2178
|
-
elapsed = now - last_rotation
|
|
2179
2367
|
cooldown = timedelta(days=self.ROTATION_MIN_INTERVAL_DAYS)
|
|
2180
2368
|
|
|
2181
|
-
|
|
2369
|
+
unlock_at = last_rotation + cooldown
|
|
2370
|
+
remaining = unlock_at - now
|
|
2371
|
+
|
|
2372
|
+
if remaining <= timedelta(0):
|
|
2182
2373
|
return True, "Cooldown passed"
|
|
2183
2374
|
|
|
2184
|
-
|
|
2185
|
-
|
|
2375
|
+
days = remaining.days
|
|
2376
|
+
hours = remaining.seconds // 3600
|
|
2377
|
+
unlock_str = unlock_at.strftime("%Y-%m-%d %H:%M UTC")
|
|
2378
|
+
return (
|
|
2379
|
+
False,
|
|
2380
|
+
f"Rotation cooldown: {days}d {hours}h remaining (unlocks {unlock_str})",
|
|
2381
|
+
)
|
|
2382
|
+
|
|
2383
|
+
async def _rotation_cooldown_hint(self) -> str:
|
|
2384
|
+
"""Human-readable rotation cooldown status for update() output."""
|
|
2385
|
+
if self.current_position is None:
|
|
2386
|
+
return ""
|
|
2387
|
+
|
|
2388
|
+
last_rotation = await self._get_last_rotation_time()
|
|
2389
|
+
if last_rotation is None:
|
|
2390
|
+
return "unlocked (cooldown inactive; no rotation recorded)"
|
|
2391
|
+
|
|
2392
|
+
now = datetime.now(UTC)
|
|
2393
|
+
if last_rotation.tzinfo is None:
|
|
2394
|
+
last_rotation = last_rotation.replace(tzinfo=UTC)
|
|
2395
|
+
|
|
2396
|
+
cooldown = timedelta(days=self.ROTATION_MIN_INTERVAL_DAYS)
|
|
2397
|
+
unlock_at = last_rotation + cooldown
|
|
2398
|
+
remaining = unlock_at - now
|
|
2399
|
+
if remaining <= timedelta(0):
|
|
2400
|
+
return "unlocked (cooldown passed)"
|
|
2401
|
+
|
|
2402
|
+
days = remaining.days
|
|
2403
|
+
hours = remaining.seconds // 3600
|
|
2404
|
+
unlock_str = unlock_at.strftime("%Y-%m-%d %H:%M UTC")
|
|
2405
|
+
return f"{days}d {hours}h remaining (unlocks {unlock_str})"
|
|
2186
2406
|
|
|
2187
2407
|
# ------------------------------------------------------------------ #
|
|
2188
2408
|
# Live Portfolio Value #
|