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.

Files changed (43) hide show
  1. wayfinder_paths/adapters/boros_adapter/adapter.py +313 -12
  2. wayfinder_paths/adapters/boros_adapter/test_adapter.py +125 -14
  3. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +17 -3
  4. wayfinder_paths/adapters/hyperliquid_adapter/exchange.py +5 -5
  5. wayfinder_paths/adapters/hyperliquid_adapter/local_signer.py +2 -33
  6. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1 -1
  7. wayfinder_paths/adapters/hyperliquid_adapter/test_cancel_order.py +57 -0
  8. wayfinder_paths/adapters/hyperliquid_adapter/test_exchange_mid_prices.py +52 -0
  9. wayfinder_paths/adapters/hyperliquid_adapter/test_hyperliquid_sdk_live.py +64 -0
  10. wayfinder_paths/adapters/hyperliquid_adapter/util.py +9 -10
  11. wayfinder_paths/core/clients/PoolClient.py +1 -1
  12. wayfinder_paths/core/constants/hype_oft_abi.py +151 -0
  13. wayfinder_paths/core/strategies/Strategy.py +1 -2
  14. wayfinder_paths/mcp/tools/execute.py +48 -16
  15. wayfinder_paths/mcp/tools/hyperliquid.py +1 -13
  16. wayfinder_paths/mcp/tools/quotes.py +38 -124
  17. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +24 -0
  18. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +249 -29
  19. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +125 -15
  20. wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +57 -201
  21. wayfinder_paths/strategies/boros_hype_strategy/constants.py +1 -152
  22. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +29 -0
  23. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2 -0
  24. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/manifest.yaml +33 -0
  25. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2 -0
  26. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +23 -0
  27. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +2 -0
  28. wayfinder_paths/tests/test_manifests.py +93 -0
  29. wayfinder_paths/tests/test_mcp_balances.py +73 -0
  30. wayfinder_paths/tests/test_mcp_discovery.py +34 -0
  31. wayfinder_paths/tests/test_mcp_execute.py +146 -0
  32. wayfinder_paths/tests/test_mcp_hyperliquid_execute.py +69 -0
  33. wayfinder_paths/tests/test_mcp_idempotency_store.py +14 -0
  34. wayfinder_paths/tests/test_mcp_quote_swap.py +60 -0
  35. wayfinder_paths/tests/test_mcp_run_script.py +47 -0
  36. wayfinder_paths/tests/test_mcp_tokens.py +49 -0
  37. wayfinder_paths/tests/test_mcp_utils.py +35 -0
  38. wayfinder_paths/tests/test_mcp_wallets.py +38 -0
  39. {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/METADATA +2 -2
  40. {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/RECORD +42 -25
  41. {wayfinder_paths-0.1.29.dist-info → wayfinder_paths-0.1.31.dist-info}/WHEEL +1 -1
  42. wayfinder_paths/core/types.py +0 -19
  43. {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.core.constants.chains import CHAIN_CODE_TO_ID
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 _chain_id(token: dict[str, Any]) -> int | None:
19
- # Token payloads often include a database/internal `id` field; do not treat that as a chain id.
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 _normalize_token_query(query: str) -> str:
48
- q = " ".join(str(query).strip().split())
49
- if not q or "-" in q or "_" in q:
50
- return q
51
- if not _SIMPLE_CHAIN_SUFFIX_RE.match(q):
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
- def _is_eth_like_token(meta: dict[str, Any]) -> bool:
60
- asset_id = str(meta.get("asset_id") or "").lower()
61
- symbol = str(meta.get("symbol") or "").lower()
62
- return asset_id == "ethereum" or symbol == "eth"
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
- def _split_asset_chain(query: str) -> tuple[str, str] | None:
66
- q = str(query).strip()
67
- if not q:
68
- return None
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
- async def _resolve_token_meta(
76
- token_client: TokenClient,
77
- *,
78
- query: str,
79
- ) -> tuple[str, dict[str, Any]]:
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
- def _slippage_float(slippage_bps: int) -> float:
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
- # API returns {"quotes": [...], "best_quote": {...}} at top level
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: HyperliquidSignCallback | None = None,
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=strategy_usdc,
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(self) -> StatusTuple:
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
- perp_margin, spot_usdc = await self._get_undeployed_capital()
1530
- total_idle = perp_margin + spot_usdc
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 total_idle > min_deploy:
1673
+ if unused_usd > min_deploy:
1534
1674
  self.logger.info(
1535
- f"Found ${total_idle:.2f} idle capital, scaling up position"
1675
+ f"Found ${unused_usd:.2f} deployable idle USDC, scaling up position"
1536
1676
  )
1537
- scale_ok, scale_msg = await self._scale_up_position(total_idle)
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
- if (
2151
- op_data.get("type") == "HYPE_SPOT"
2152
- and op_data.get("buy_or_sell") == "buy"
2153
- ):
2154
- created_str = txn.get("created")
2155
- if created_str:
2156
- return datetime.fromisoformat(
2157
- str(created_str).replace("Z", "+00:00")
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
- if elapsed >= cooldown:
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
- remaining = cooldown - elapsed
2185
- return False, f"Rotation cooldown: {remaining.days} days remaining"
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 #