wayfinder-paths 0.1.22__py3-none-any.whl → 0.1.24__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 (156) hide show
  1. wayfinder_paths/__init__.py +0 -4
  2. wayfinder_paths/adapters/balance_adapter/README.md +0 -1
  3. wayfinder_paths/adapters/balance_adapter/adapter.py +313 -167
  4. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  5. wayfinder_paths/adapters/balance_adapter/test_adapter.py +41 -124
  6. wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
  7. wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
  8. wayfinder_paths/adapters/boros_adapter/client.py +476 -0
  9. wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
  10. wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
  11. wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
  12. wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
  13. wayfinder_paths/adapters/boros_adapter/types.py +70 -0
  14. wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
  15. wayfinder_paths/adapters/brap_adapter/README.md +22 -75
  16. wayfinder_paths/adapters/brap_adapter/adapter.py +187 -576
  17. wayfinder_paths/adapters/brap_adapter/examples.json +21 -140
  18. wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
  19. wayfinder_paths/adapters/brap_adapter/test_adapter.py +6 -234
  20. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +180 -92
  21. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
  22. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +82 -14
  23. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
  24. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +586 -61
  25. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
  26. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
  27. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
  28. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
  29. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
  30. wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
  31. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
  32. wayfinder_paths/adapters/ledger_adapter/README.md +4 -1
  33. wayfinder_paths/adapters/ledger_adapter/adapter.py +3 -3
  34. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
  35. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
  36. wayfinder_paths/adapters/moonwell_adapter/adapter.py +649 -547
  37. wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
  38. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +160 -239
  39. wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
  40. wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
  41. wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
  42. wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
  43. wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
  44. wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
  45. wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
  46. wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
  47. wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
  48. wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
  49. wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
  50. wayfinder_paths/adapters/token_adapter/adapter.py +14 -0
  51. wayfinder_paths/adapters/token_adapter/examples.json +0 -4
  52. wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
  53. wayfinder_paths/conftest.py +24 -17
  54. wayfinder_paths/core/__init__.py +0 -3
  55. wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
  56. wayfinder_paths/core/adapters/models.py +17 -7
  57. wayfinder_paths/core/clients/BRAPClient.py +4 -1
  58. wayfinder_paths/core/clients/ClientManager.py +0 -7
  59. wayfinder_paths/core/clients/LedgerClient.py +196 -172
  60. wayfinder_paths/core/clients/TokenClient.py +47 -1
  61. wayfinder_paths/core/clients/WayfinderClient.py +1 -3
  62. wayfinder_paths/core/clients/__init__.py +0 -5
  63. wayfinder_paths/core/clients/protocols.py +21 -35
  64. wayfinder_paths/core/clients/test_ledger_client.py +448 -0
  65. wayfinder_paths/core/config.py +10 -162
  66. wayfinder_paths/core/constants/__init__.py +73 -2
  67. wayfinder_paths/core/constants/base.py +8 -17
  68. wayfinder_paths/core/constants/chains.py +36 -0
  69. wayfinder_paths/core/constants/contracts.py +52 -0
  70. wayfinder_paths/core/constants/erc20_abi.py +0 -1
  71. wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
  72. wayfinder_paths/core/constants/hyperliquid.py +16 -0
  73. wayfinder_paths/core/constants/moonwell_abi.py +0 -15
  74. wayfinder_paths/core/constants/tokens.py +9 -0
  75. wayfinder_paths/core/engine/manifest.py +66 -0
  76. wayfinder_paths/core/strategies/Strategy.py +0 -71
  77. wayfinder_paths/core/strategies/__init__.py +10 -1
  78. wayfinder_paths/core/strategies/opa_loop.py +167 -0
  79. wayfinder_paths/core/utils/evm_helpers.py +5 -15
  80. wayfinder_paths/core/utils/test_transaction.py +289 -0
  81. wayfinder_paths/core/utils/tokens.py +28 -0
  82. wayfinder_paths/core/utils/transaction.py +57 -8
  83. wayfinder_paths/core/utils/web3.py +8 -3
  84. wayfinder_paths/mcp/__init__.py +5 -0
  85. wayfinder_paths/mcp/preview.py +185 -0
  86. wayfinder_paths/mcp/scripting.py +84 -0
  87. wayfinder_paths/mcp/server.py +52 -0
  88. wayfinder_paths/mcp/state/profile_store.py +195 -0
  89. wayfinder_paths/mcp/state/store.py +89 -0
  90. wayfinder_paths/mcp/test_scripting.py +267 -0
  91. wayfinder_paths/mcp/tools/__init__.py +0 -0
  92. wayfinder_paths/mcp/tools/balances.py +290 -0
  93. wayfinder_paths/mcp/tools/discovery.py +158 -0
  94. wayfinder_paths/mcp/tools/execute.py +770 -0
  95. wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
  96. wayfinder_paths/mcp/tools/quotes.py +288 -0
  97. wayfinder_paths/mcp/tools/run_script.py +286 -0
  98. wayfinder_paths/mcp/tools/strategies.py +188 -0
  99. wayfinder_paths/mcp/tools/tokens.py +46 -0
  100. wayfinder_paths/mcp/tools/wallets.py +354 -0
  101. wayfinder_paths/mcp/utils.py +129 -0
  102. wayfinder_paths/policies/enso.py +1 -2
  103. wayfinder_paths/policies/hyper_evm.py +6 -3
  104. wayfinder_paths/policies/hyperlend.py +1 -2
  105. wayfinder_paths/policies/hyperliquid.py +1 -1
  106. wayfinder_paths/policies/lifi.py +18 -0
  107. wayfinder_paths/policies/moonwell.py +12 -7
  108. wayfinder_paths/policies/prjx.py +1 -3
  109. wayfinder_paths/policies/util.py +8 -2
  110. wayfinder_paths/run_strategy.py +97 -300
  111. wayfinder_paths/strategies/basis_trading_strategy/constants.py +3 -1
  112. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +47 -133
  113. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
  114. wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
  115. wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
  116. wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
  117. wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
  118. wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
  119. wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
  120. wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
  121. wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
  122. wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
  123. wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
  124. wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
  125. wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
  126. wayfinder_paths/{templates/strategy → strategies/boros_hype_strategy}/test_strategy.py +99 -63
  127. wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
  128. wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
  129. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +15 -23
  130. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +27 -62
  131. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +84 -58
  132. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
  133. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +69 -164
  134. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +43 -76
  135. wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
  136. wayfinder_paths/tests/test_test_coverage.py +1 -4
  137. wayfinder_paths-0.1.24.dist-info/METADATA +378 -0
  138. wayfinder_paths-0.1.24.dist-info/RECORD +185 -0
  139. {wayfinder_paths-0.1.22.dist-info → wayfinder_paths-0.1.24.dist-info}/WHEEL +1 -1
  140. wayfinder_paths/core/clients/WalletClient.py +0 -41
  141. wayfinder_paths/core/engine/StrategyJob.py +0 -110
  142. wayfinder_paths/core/services/test_local_evm_txn.py +0 -145
  143. wayfinder_paths/scripts/create_strategy.py +0 -139
  144. wayfinder_paths/scripts/make_wallets.py +0 -142
  145. wayfinder_paths/templates/adapter/README.md +0 -150
  146. wayfinder_paths/templates/adapter/adapter.py +0 -16
  147. wayfinder_paths/templates/adapter/examples.json +0 -8
  148. wayfinder_paths/templates/adapter/test_adapter.py +0 -30
  149. wayfinder_paths/templates/strategy/README.md +0 -186
  150. wayfinder_paths/templates/strategy/examples.json +0 -11
  151. wayfinder_paths/templates/strategy/strategy.py +0 -35
  152. wayfinder_paths/tests/test_smoke_manifest.py +0 -63
  153. wayfinder_paths-0.1.22.dist-info/METADATA +0 -355
  154. wayfinder_paths-0.1.22.dist-info/RECORD +0 -129
  155. /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
  156. {wayfinder_paths-0.1.22.dist-info → wayfinder_paths-0.1.24.dist-info}/LICENSE +0 -0
@@ -0,0 +1,1574 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from eth_abi import encode
9
+ from eth_utils import function_signature_to_4byte_selector, to_checksum_address
10
+ from loguru import logger
11
+
12
+ from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
13
+ from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
14
+ from wayfinder_paths.core.constants.contracts import BOROS_MARKET_HUB, BOROS_ROUTER
15
+ from wayfinder_paths.core.utils.tokens import build_approve_transaction
16
+ from wayfinder_paths.core.utils.transaction import send_transaction
17
+ from wayfinder_paths.core.utils.web3 import web3_from_chain_id
18
+
19
+ from .client import BorosClient
20
+ from .parsers import (
21
+ extract_collateral,
22
+ extract_maturity_ts,
23
+ extract_symbol,
24
+ extract_underlying,
25
+ parse_market_position,
26
+ time_to_maturity_days,
27
+ )
28
+ from .types import BorosLimitOrder, BorosMarketQuote
29
+ from .utils import (
30
+ BOROS_TICK_BASE,
31
+ cash_wei_to_float,
32
+ market_id_from_market_acc,
33
+ )
34
+ from .utils import (
35
+ normalize_apr as _normalize_apr,
36
+ )
37
+ from .utils import (
38
+ rate_from_tick as _rate_from_tick,
39
+ )
40
+ from .utils import (
41
+ tick_from_rate as _tick_from_rate,
42
+ )
43
+
44
+
45
+ class BorosAdapter(BaseAdapter):
46
+ """Adapter for Boros fixed-rate market operations.
47
+
48
+ Provides methods for:
49
+ - Market data: list markets, get quotes, orderbook
50
+ - Account data: collaterals, balances, positions, open orders
51
+ - Execution: deposit, withdraw, place orders, close positions
52
+ """
53
+
54
+ adapter_type = "BOROS"
55
+
56
+ def __init__(
57
+ self,
58
+ config: dict[str, Any] | None = None,
59
+ *,
60
+ sign_callback: Callable | None = None,
61
+ simulation: bool = False,
62
+ user_address: str | None = None,
63
+ account_id: int = 0,
64
+ ) -> None:
65
+ super().__init__("boros_adapter", config)
66
+
67
+ self.simulation = simulation
68
+ self.sign_callback = sign_callback
69
+ self._scaling_factor_cache: dict[int, int] = {}
70
+
71
+ boros_cfg = (config or {}).get("boros_adapter", {})
72
+ self.chain_id = int(boros_cfg.get("chain_id", 42161))
73
+
74
+ # Extract user address from config if not provided
75
+ if not user_address:
76
+ wallet = (config or {}).get("strategy_wallet") or (config or {}).get(
77
+ "main_wallet"
78
+ )
79
+ if wallet and isinstance(wallet, dict):
80
+ user_address = wallet.get("address")
81
+
82
+ self.user_address = user_address
83
+ self.account_id = boros_cfg.get("account_id", account_id)
84
+
85
+ self.boros_client = BorosClient(
86
+ base_url=boros_cfg.get("base_url", "https://api.boros.finance"),
87
+ endpoints=boros_cfg.get("endpoints"),
88
+ user_address=user_address,
89
+ account_id=self.account_id,
90
+ )
91
+
92
+ # ------------------------------------------------------------------ #
93
+ # Transaction Helpers #
94
+ # ------------------------------------------------------------------ #
95
+
96
+ BOROS_MARKET_HUB_VIEW_ABI = [
97
+ {
98
+ "inputs": [
99
+ {
100
+ "internalType": "address",
101
+ "name": "user",
102
+ "type": "address",
103
+ }
104
+ ],
105
+ "name": "getPersonalCooldown",
106
+ "outputs": [
107
+ {
108
+ "internalType": "uint256",
109
+ "name": "",
110
+ "type": "uint256",
111
+ }
112
+ ],
113
+ "stateMutability": "view",
114
+ "type": "function",
115
+ }
116
+ ]
117
+
118
+ @staticmethod
119
+ def _unwrap_tx_payload(payload: dict[str, Any]) -> dict[str, Any]:
120
+ """Best-effort unwrap of API payloads that may nest the tx dict."""
121
+ tx_src: Any = payload
122
+ for key in ("data", "calldata", "transaction", "tx", "result"):
123
+ if isinstance(tx_src, dict) and isinstance(tx_src.get(key), dict):
124
+ tx_src = tx_src[key]
125
+ return tx_src if isinstance(tx_src, dict) else payload
126
+
127
+ def _build_tx_from_calldata(
128
+ self,
129
+ calldata: dict[str, Any],
130
+ *,
131
+ from_address: str,
132
+ ) -> dict[str, Any]:
133
+ """Build a transaction dict from Boros API calldata.
134
+
135
+ NOTE: We intentionally do NOT copy 'gas' from the API response.
136
+ The Boros API sometimes returns incorrect gas values (e.g., 1234).
137
+ Instead, we let the transaction service estimate gas properly.
138
+ """
139
+ tx_src = self._unwrap_tx_payload(calldata)
140
+
141
+ to_addr = tx_src.get("to") or calldata.get("to")
142
+
143
+ # Handle v3 API format that returns {'calldatas': ['0x...']} without 'to' address
144
+ data_val = tx_src.get("data") or calldata.get("data")
145
+ if not data_val:
146
+ # Check for calldatas array format (v3 API)
147
+ calldatas = calldata.get("calldatas") or tx_src.get("calldatas")
148
+ if isinstance(calldatas, list) and len(calldatas) > 0:
149
+ data_val = calldatas[0]
150
+ # Use Router address when calldatas format is used (for calldata execution)
151
+ if not to_addr:
152
+ to_addr = BOROS_ROUTER
153
+ logger.debug(
154
+ f"Using Boros Router address for calldatas format: {to_addr}"
155
+ )
156
+
157
+ if not isinstance(to_addr, str) or not to_addr:
158
+ raise ValueError("Boros calldata missing 'to' address")
159
+
160
+ if not data_val:
161
+ data_val = "0x"
162
+ if not isinstance(data_val, str):
163
+ raise ValueError("Boros calldata missing 'data' field")
164
+
165
+ chain_id_val = (
166
+ tx_src.get("chainId")
167
+ or tx_src.get("chain_id")
168
+ or calldata.get("chainId")
169
+ or calldata.get("chain_id")
170
+ )
171
+ try:
172
+ chain_id_int = (
173
+ int(chain_id_val) if chain_id_val is not None else int(self.chain_id)
174
+ )
175
+ except (TypeError, ValueError):
176
+ chain_id_int = int(self.chain_id)
177
+
178
+ value_val = (
179
+ tx_src.get("value") if "value" in tx_src else calldata.get("value", 0)
180
+ )
181
+ try:
182
+ value_int = int(value_val) if value_val is not None else 0
183
+ except (TypeError, ValueError):
184
+ value_int = 0
185
+
186
+ return {
187
+ "chainId": int(chain_id_int),
188
+ "from": to_checksum_address(from_address),
189
+ "to": to_checksum_address(to_addr),
190
+ "data": data_val if data_val.startswith("0x") else f"0x{data_val}",
191
+ "value": int(value_int),
192
+ }
193
+
194
+ async def _broadcast_calldata(
195
+ self,
196
+ calldata: dict[str, Any],
197
+ *,
198
+ timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
199
+ max_retries: int = 2,
200
+ ) -> tuple[bool, dict[str, Any]]:
201
+ """Broadcast calldata from Boros API with retry logic.
202
+
203
+ Handles multiple formats:
204
+ - {"calldatas": ["0x...", "0x..."]} - execute each sequentially to Router
205
+ - {"data": "0x...", "to": "0x..."} - standard format
206
+
207
+ Args:
208
+ calldata: Transaction calldata from Boros API.
209
+ timeout: Transaction timeout in seconds.
210
+ max_retries: Number of retry attempts for failed transactions.
211
+ """
212
+ if not self.sign_callback:
213
+ return False, {
214
+ "error": "sign_callback not configured",
215
+ "calldata": calldata,
216
+ }
217
+ if not self.user_address:
218
+ return False, {"error": "user_address not configured", "calldata": calldata}
219
+
220
+ # Check for calldatas array format (multiple transactions)
221
+ calldatas = calldata.get("calldatas")
222
+ if isinstance(calldatas, list) and len(calldatas) > 1:
223
+ # Execute each calldata sequentially (multi-tx response)
224
+ results = []
225
+ for i, data in enumerate(calldatas):
226
+ single_calldata = {"data": data, "to": BOROS_ROUTER}
227
+ tx = self._build_tx_from_calldata(
228
+ single_calldata, from_address=self.user_address
229
+ )
230
+ logger.debug(
231
+ f"Broadcasting calldata {i + 1}/{len(calldatas)} to {tx.get('to')}"
232
+ )
233
+ try:
234
+ tx_hash = await send_transaction(
235
+ tx, self.sign_callback, wait_for_receipt=True
236
+ )
237
+ results.append({"ok": True, "res": {"tx_hash": tx_hash}})
238
+ except Exception as e:
239
+ results.append({"ok": False, "res": {"error": str(e)}})
240
+ return False, {
241
+ "status": "error",
242
+ "error": f"Failed on calldata {i + 1}/{len(calldatas)}: {e}",
243
+ "tx": {"error": str(e)},
244
+ "calldata": calldata,
245
+ "partial_results": results,
246
+ }
247
+ return True, {
248
+ "status": "ok",
249
+ "tx": results[-1]["res"],
250
+ "calldata": calldata,
251
+ "all_results": results,
252
+ }
253
+
254
+ # Single calldata (standard format) with retry logic
255
+ last_error = None
256
+ for attempt in range(max_retries + 1):
257
+ tx = self._build_tx_from_calldata(calldata, from_address=self.user_address)
258
+ try:
259
+ tx_hash = await send_transaction(
260
+ tx, self.sign_callback, wait_for_receipt=True
261
+ )
262
+ return True, {
263
+ "status": "ok",
264
+ "tx": {"tx_hash": tx_hash},
265
+ "calldata": calldata,
266
+ }
267
+ except Exception as e:
268
+ last_error = str(e)
269
+ error_str = str(e).lower()
270
+ # Check if it's a revert (not worth retrying) vs transient error
271
+ if "revert" in error_str:
272
+ logger.warning(
273
+ f"Boros transaction reverted on attempt {attempt + 1}/{max_retries + 1}: {e}"
274
+ )
275
+ # For reverts, wait a bit and retry in case it's a timing issue
276
+ if attempt < max_retries:
277
+ await asyncio.sleep(2 * (attempt + 1))
278
+ continue
279
+ else:
280
+ # Non-revert error, log and retry
281
+ logger.warning(
282
+ f"Boros transaction failed on attempt {attempt + 1}/{max_retries + 1}: {e}"
283
+ )
284
+ if attempt < max_retries:
285
+ await asyncio.sleep(1)
286
+
287
+ return False, {
288
+ "status": "error",
289
+ "error": str(last_error),
290
+ "tx": last_error,
291
+ "calldata": calldata,
292
+ "attempts": max_retries + 1,
293
+ }
294
+
295
+ async def connect(self) -> bool:
296
+ try:
297
+ markets = await self.boros_client.list_markets(limit=1)
298
+ return len(markets) > 0
299
+ except Exception as exc:
300
+ logger.error(f"BorosAdapter connection failed: {exc}")
301
+ return False
302
+
303
+ # ------------------------------------------------------------------ #
304
+ # Tick Math Utilities #
305
+ # ------------------------------------------------------------------ #
306
+
307
+ @classmethod
308
+ def tick_from_rate(cls, rate: float, tick_step: int, *, round_down: bool) -> int:
309
+ """Convert APR rate to Boros limitTick.
310
+
311
+ Args:
312
+ rate: APR as decimal (e.g., 0.11 = 11%).
313
+ tick_step: Market's tickStep from metadata.
314
+ round_down: If True, round toward zero (for shorts).
315
+
316
+ Returns:
317
+ limitTick value for Boros API.
318
+ """
319
+ return _tick_from_rate(
320
+ rate,
321
+ tick_step,
322
+ round_down=round_down,
323
+ base=BOROS_TICK_BASE,
324
+ )
325
+
326
+ @classmethod
327
+ def rate_from_tick(cls, tick: int, tick_step: int) -> float:
328
+ """Convert Boros limitTick to APR rate.
329
+
330
+ Args:
331
+ tick: Boros tick value.
332
+ tick_step: Market's tickStep.
333
+
334
+ Returns:
335
+ APR as decimal (e.g., 0.11 = 11%).
336
+ """
337
+ return _rate_from_tick(tick, tick_step, base=BOROS_TICK_BASE)
338
+
339
+ @staticmethod
340
+ def normalize_apr(value: Any) -> float | None:
341
+ """Normalize various APR encodings to decimal.
342
+
343
+ Handles: decimal (0.1115), percent (11.15), bps (1115), 1e18-scaled.
344
+ """
345
+ return _normalize_apr(value)
346
+
347
+ # ------------------------------------------------------------------ #
348
+ # Market Data #
349
+ # ------------------------------------------------------------------ #
350
+
351
+ async def list_markets(
352
+ self,
353
+ *,
354
+ is_whitelisted: bool | None = True,
355
+ skip: int = 0,
356
+ limit: int = 100,
357
+ ) -> tuple[bool, list[dict[str, Any]]]:
358
+ try:
359
+ markets = await self.boros_client.list_markets(
360
+ is_whitelisted=is_whitelisted, skip=skip, limit=limit
361
+ )
362
+ return True, markets
363
+ except Exception as e:
364
+ logger.error(f"Failed to list markets: {e}")
365
+ return False, str(e) # type: ignore
366
+
367
+ async def get_market(self, market_id: int) -> tuple[bool, dict[str, Any]]:
368
+ try:
369
+ market = await self.boros_client.get_market(market_id)
370
+ return True, market
371
+ except Exception as e:
372
+ logger.error(f"Failed to get market {market_id}: {e}")
373
+ return False, str(e) # type: ignore
374
+
375
+ async def get_orderbook(
376
+ self, market_id: int, *, tick_size: float = 0.001
377
+ ) -> tuple[bool, dict[str, Any]]:
378
+ try:
379
+ book = await self.boros_client.get_order_book(
380
+ market_id, tick_size=tick_size
381
+ )
382
+ return True, book
383
+ except Exception as e:
384
+ logger.error(f"Failed to get orderbook for market {market_id}: {e}")
385
+ return False, str(e) # type: ignore
386
+
387
+ async def quote_market(
388
+ self, market: dict[str, Any], *, tick_size: float = 0.001
389
+ ) -> tuple[bool, BorosMarketQuote]:
390
+ try:
391
+ market_id = int(market.get("marketId") or market.get("id") or 0)
392
+ market_address = market.get("address") or market.get("marketAddress") or ""
393
+ if not market_id:
394
+ raise ValueError("Market missing marketId/id")
395
+
396
+ orderbook = await self.boros_client.get_order_book(
397
+ market_id, tick_size=tick_size
398
+ )
399
+
400
+ long_side = orderbook.get("long") or {}
401
+ short_side = orderbook.get("short") or {}
402
+
403
+ long_ticks = long_side.get("ia") or []
404
+ short_ticks = short_side.get("ia") or []
405
+
406
+ bid_apr: float | None = None
407
+ ask_apr: float | None = None
408
+
409
+ # Best bid = highest rate long side is willing to pay
410
+ if long_ticks:
411
+ best_bid_tick = max(long_ticks)
412
+ bid_apr = float(best_bid_tick) * tick_size
413
+
414
+ # Best ask = lowest rate short side willing to receive
415
+ if short_ticks:
416
+ best_ask_tick = min(short_ticks)
417
+ ask_apr = float(best_ask_tick) * tick_size
418
+
419
+ if bid_apr is not None and ask_apr is not None:
420
+ mid_apr = (bid_apr + ask_apr) / 2.0
421
+ else:
422
+ mid_apr = bid_apr if bid_apr is not None else ask_apr
423
+
424
+ maturity_ts = self._extract_maturity_ts(market)
425
+ tenor_days = (
426
+ self._time_to_maturity_days(maturity_ts) if maturity_ts else 0.0
427
+ )
428
+
429
+ quote = BorosMarketQuote(
430
+ market_id=market_id,
431
+ market_address=market_address,
432
+ symbol=self._extract_symbol(market),
433
+ underlying=self._extract_underlying(market),
434
+ tenor_days=tenor_days,
435
+ maturity_ts=maturity_ts or 0,
436
+ collateral_address=self._extract_collateral(market),
437
+ collateral_token_id=market.get("tokenId"),
438
+ tick_step=(market.get("imData") or {}).get("tickStep"),
439
+ mid_apr=mid_apr,
440
+ best_bid_apr=bid_apr,
441
+ best_ask_apr=ask_apr,
442
+ )
443
+ return True, quote
444
+ except Exception as e:
445
+ logger.error(f"Failed to quote market: {e}")
446
+ return False, str(e) # type: ignore
447
+
448
+ async def quote_markets_for_underlying(
449
+ self,
450
+ underlying_symbol: str,
451
+ *,
452
+ platform: str | None = None,
453
+ max_markets: int = 50,
454
+ tick_size: float = 0.001,
455
+ ) -> tuple[bool, list[BorosMarketQuote]]:
456
+ try:
457
+ markets = await self.boros_client.list_markets(
458
+ is_whitelisted=True, skip=0, limit=max_markets
459
+ )
460
+ target = underlying_symbol.upper()
461
+ platform_filter = platform.upper() if platform else None
462
+
463
+ def _matches(mkt: dict[str, Any]) -> bool:
464
+ under = self._extract_underlying(mkt).upper()
465
+ sym = self._extract_symbol(mkt).upper()
466
+ under_match = target == under
467
+ sym_parts = sym.replace("_", "-").split("-")
468
+ sym_match = target in sym_parts
469
+ if not under_match and not sym_match:
470
+ return False
471
+ if platform_filter:
472
+ metadata = mkt.get("metadata") or {}
473
+ plat = mkt.get("platform") or {}
474
+ plat_name = (
475
+ metadata.get("platformName") or plat.get("name") or ""
476
+ ).upper()
477
+ if platform_filter not in plat_name and not sym.startswith(
478
+ platform_filter
479
+ ):
480
+ return False
481
+ return True
482
+
483
+ filtered = [m for m in markets if _matches(m)]
484
+ quotes: list[BorosMarketQuote] = []
485
+
486
+ for market in filtered:
487
+ try:
488
+ success, quote = await self.quote_market(
489
+ market, tick_size=tick_size
490
+ )
491
+ if success:
492
+ quotes.append(quote)
493
+ except Exception as e:
494
+ market_id = market.get("marketId") or market.get("id")
495
+ logger.warning(f"quote_market failed for {market_id}: {e}")
496
+
497
+ quotes.sort(key=lambda q: q.maturity_ts)
498
+ return True, quotes
499
+ except Exception as e:
500
+ logger.error(f"Failed to quote markets for {underlying_symbol}: {e}")
501
+ return False, str(e) # type: ignore
502
+
503
+ # ------------------------------------------------------------------ #
504
+ # Account Data #
505
+ # ------------------------------------------------------------------ #
506
+
507
+ async def get_collaterals(
508
+ self, *, account_id: int | None = None
509
+ ) -> tuple[bool, dict[str, Any]]:
510
+ try:
511
+ data = await self.boros_client.get_collaterals(
512
+ user_address=self.user_address,
513
+ account_id=account_id,
514
+ )
515
+ return True, data
516
+ except Exception as e:
517
+ logger.error(f"Failed to get collaterals: {e}")
518
+ return False, str(e) # type: ignore
519
+
520
+ async def get_account_balances(
521
+ self, token_id: int = 3, *, account_id: int | None = None
522
+ ) -> tuple[bool, dict[str, Any]]:
523
+ result = {
524
+ "isolated": 0.0,
525
+ "cross": 0.0,
526
+ "total": 0.0,
527
+ "isolated_wei": 0,
528
+ "cross_wei": 0,
529
+ "isolated_market_id": None,
530
+ "isolated_positions": [],
531
+ }
532
+
533
+ try:
534
+ success, summary = await self.get_collaterals(account_id=account_id)
535
+ if not success:
536
+ return False, str(summary) # type: ignore
537
+
538
+ collaterals = summary.get("collaterals", [])
539
+ for coll in collaterals:
540
+ if coll.get("tokenId") != token_id:
541
+ continue
542
+
543
+ # Isolated positions
544
+ for iso in coll.get("isolatedPositions", []):
545
+ net_raw = iso.get("availableBalance") or iso.get("netBalance")
546
+ if net_raw:
547
+ try:
548
+ wei = int(net_raw)
549
+ result["isolated_wei"] += wei
550
+ result["isolated"] += cash_wei_to_float(net_raw)
551
+ # Extract market ID from marketAcc (last 6 hex chars = 3 bytes)
552
+ market_acc = iso.get("marketAcc", "")
553
+ market_id = market_id_from_market_acc(market_acc)
554
+ if market_id is not None:
555
+ result["isolated_market_id"] = market_id
556
+ result["isolated_positions"].append(
557
+ {
558
+ "market_id": market_id,
559
+ "balance": cash_wei_to_float(net_raw),
560
+ "balance_wei": wei,
561
+ "marketAcc": market_acc,
562
+ }
563
+ )
564
+ except Exception:
565
+ pass
566
+
567
+ # Cross position
568
+ cross = coll.get("crossPosition", {})
569
+ cross_raw = cross.get("availableBalance") or cross.get("netBalance")
570
+ if cross_raw:
571
+ try:
572
+ wei = int(cross_raw)
573
+ result["cross_wei"] += wei
574
+ result["cross"] += cash_wei_to_float(cross_raw)
575
+ except Exception:
576
+ pass
577
+
578
+ result["total"] = result["isolated"] + result["cross"]
579
+ result["raw"] = summary # Include raw data for marketAcc lookup
580
+ return True, result
581
+ except Exception as e:
582
+ logger.error(f"Failed to get account balances: {e}")
583
+ return False, str(e) # type: ignore
584
+
585
+ async def get_active_positions(
586
+ self, market_id: int | None = None
587
+ ) -> tuple[bool, list[dict[str, Any]]]:
588
+ try:
589
+ success, collaterals = await self.get_collaterals()
590
+ if not success:
591
+ return False, []
592
+
593
+ coll_list = collaterals.get("collaterals", [])
594
+ positions: list[dict[str, Any]] = []
595
+
596
+ for entry in coll_list:
597
+ token_id = entry.get("tokenId")
598
+
599
+ # Cross position
600
+ cross_pos = entry.get("crossPosition", {})
601
+ for mkt_pos in cross_pos.get("marketPositions", []):
602
+ pos = self._parse_market_position(mkt_pos, token_id, is_cross=True)
603
+ if pos:
604
+ positions.append(pos)
605
+
606
+ # Isolated positions
607
+ for iso_pos in entry.get("isolatedPositions", []):
608
+ for mkt_pos in iso_pos.get("marketPositions", []):
609
+ pos = self._parse_market_position(
610
+ mkt_pos, token_id, is_cross=False
611
+ )
612
+ if pos:
613
+ positions.append(pos)
614
+
615
+ if market_id is not None:
616
+ positions = [p for p in positions if p.get("marketId") == market_id]
617
+
618
+ return True, positions
619
+ except Exception as e:
620
+ logger.error(f"Failed to get active positions: {e}")
621
+ return False, str(e) # type: ignore
622
+
623
+ async def get_open_limit_orders(
624
+ self, *, limit: int = 50
625
+ ) -> tuple[bool, list[BorosLimitOrder]]:
626
+ try:
627
+ orders_raw = await self.boros_client.get_open_orders(
628
+ user_address=self.user_address, limit=limit
629
+ )
630
+
631
+ orders: list[BorosLimitOrder] = []
632
+ for o in orders_raw:
633
+ try:
634
+ tick = int(o.get("limitTick") or 0)
635
+ tick_step = int(o.get("tickStep") or 1)
636
+ apr = self.rate_from_tick(tick, tick_step)
637
+
638
+ size = float(o.get("size") or 0) / 1e18
639
+ filled = float(o.get("filledSize") or 0) / 1e18
640
+ remaining = size - filled
641
+
642
+ orders.append(
643
+ BorosLimitOrder(
644
+ order_id=str(o.get("orderId") or o.get("id") or ""),
645
+ market_id=int(o.get("marketId") or 0),
646
+ side="long" if int(o.get("side") or 0) == 0 else "short",
647
+ size=size,
648
+ limit_tick=tick,
649
+ limit_apr=apr,
650
+ filled_size=filled,
651
+ remaining_size=remaining,
652
+ status=o.get("status") or "open",
653
+ raw=o,
654
+ )
655
+ )
656
+ except Exception as e:
657
+ logger.warning(f"Failed to parse order: {e}")
658
+
659
+ return True, orders
660
+ except Exception as e:
661
+ logger.error(f"Failed to get open orders: {e}")
662
+ return False, str(e) # type: ignore
663
+
664
+ async def get_full_user_state(
665
+ self,
666
+ *,
667
+ account: str | None = None,
668
+ account_id: int | None = None,
669
+ token_id: int = 3,
670
+ token_decimals: int = 6,
671
+ open_orders_limit: int = 50,
672
+ include_open_orders: bool = True,
673
+ include_withdrawal_status: bool = True,
674
+ ) -> tuple[bool, dict[str, Any] | str]:
675
+ """
676
+ Full Boros user state snapshot.
677
+
678
+ Pulls:
679
+ - Collaterals summary (raw)
680
+ - Parsed positions (cross + isolated)
681
+ - Cash balances for token_id (cross/isolated/total)
682
+ - Open limit orders (optional)
683
+ - Withdrawal status (optional)
684
+ """
685
+ addr = account or self.user_address
686
+ if not addr:
687
+ return False, "user_address not configured"
688
+
689
+ try:
690
+ collaterals = await self.boros_client.get_collaterals(
691
+ user_address=addr,
692
+ account_id=int(
693
+ account_id if account_id is not None else self.account_id
694
+ ),
695
+ )
696
+
697
+ coll_list = collaterals.get("collaterals", [])
698
+
699
+ # Positions (cross + isolated)
700
+ positions: list[dict[str, Any]] = []
701
+ for entry in coll_list:
702
+ tid = entry.get("tokenId")
703
+
704
+ cross_pos = entry.get("crossPosition", {})
705
+ for mkt_pos in cross_pos.get("marketPositions", []):
706
+ pos = self._parse_market_position(mkt_pos, tid, is_cross=True)
707
+ if pos:
708
+ positions.append(pos)
709
+
710
+ for iso_pos in entry.get("isolatedPositions", []):
711
+ for mkt_pos in iso_pos.get("marketPositions", []):
712
+ pos = self._parse_market_position(mkt_pos, tid, is_cross=False)
713
+ if pos:
714
+ positions.append(pos)
715
+
716
+ # Cash balances (token_id only)
717
+ balances: dict[str, Any] = {
718
+ "token_id": int(token_id),
719
+ "isolated": 0.0,
720
+ "cross": 0.0,
721
+ "total": 0.0,
722
+ "isolated_wei": 0,
723
+ "cross_wei": 0,
724
+ "isolated_market_id": None,
725
+ "isolated_positions": [],
726
+ }
727
+ for coll in coll_list:
728
+ if coll.get("tokenId") != int(token_id):
729
+ continue
730
+
731
+ for iso in coll.get("isolatedPositions", []):
732
+ net_raw = iso.get("availableBalance") or iso.get("netBalance")
733
+ if net_raw:
734
+ try:
735
+ wei = int(net_raw)
736
+ balances["isolated_wei"] += wei
737
+ balances["isolated"] += cash_wei_to_float(net_raw)
738
+ market_acc = iso.get("marketAcc", "")
739
+ market_id = market_id_from_market_acc(market_acc)
740
+ if market_id is not None:
741
+ balances["isolated_market_id"] = market_id
742
+ balances["isolated_positions"].append(
743
+ {
744
+ "market_id": market_id,
745
+ "balance": cash_wei_to_float(net_raw),
746
+ "balance_wei": wei,
747
+ "marketAcc": market_acc,
748
+ }
749
+ )
750
+ except Exception:
751
+ pass
752
+
753
+ cross = coll.get("crossPosition", {})
754
+ cross_raw = cross.get("availableBalance") or cross.get("netBalance")
755
+ if cross_raw:
756
+ try:
757
+ wei = int(cross_raw)
758
+ balances["cross_wei"] += wei
759
+ balances["cross"] += cash_wei_to_float(cross_raw)
760
+ except Exception:
761
+ pass
762
+
763
+ balances["total"] = balances["isolated"] + balances["cross"]
764
+
765
+ # Orders
766
+ orders: list[dict[str, Any]] | None = None
767
+ if include_open_orders:
768
+ try:
769
+ orders_raw = await self.boros_client.get_open_orders(
770
+ user_address=addr, limit=int(open_orders_limit)
771
+ )
772
+ parsed: list[dict[str, Any]] = []
773
+ for o in orders_raw:
774
+ try:
775
+ tick = int(o.get("limitTick") or 0)
776
+ tick_step = int(o.get("tickStep") or 1)
777
+ apr = self.rate_from_tick(tick, tick_step)
778
+
779
+ size = float(o.get("size") or 0) / 1e18
780
+ filled = float(o.get("filledSize") or 0) / 1e18
781
+ remaining = size - filled
782
+
783
+ parsed.append(
784
+ {
785
+ "order_id": str(
786
+ o.get("orderId") or o.get("id") or ""
787
+ ),
788
+ "market_id": int(o.get("marketId") or 0),
789
+ "side": "long"
790
+ if int(o.get("side") or 0) == 0
791
+ else "short",
792
+ "size": size,
793
+ "limit_tick": tick,
794
+ "limit_apr": apr,
795
+ "filled_size": filled,
796
+ "remaining_size": remaining,
797
+ "status": o.get("status") or "open",
798
+ "raw": o,
799
+ }
800
+ )
801
+ except Exception as exc:
802
+ logger.warning(f"Failed to parse order: {exc}")
803
+ orders = parsed
804
+ except Exception as exc: # noqa: BLE001
805
+ logger.warning(f"Failed to fetch open orders: {exc}")
806
+ orders = None
807
+
808
+ # Withdrawal status
809
+ withdrawal_status: dict[str, Any] | None = None
810
+ if include_withdrawal_status:
811
+ cooldown_seconds: int | None = None
812
+ cooldown_source = "unknown"
813
+ try:
814
+ async with web3_from_chain_id(self.chain_id) as web3:
815
+ market_hub = web3.eth.contract(
816
+ address=to_checksum_address(BOROS_MARKET_HUB),
817
+ abi=self.BOROS_MARKET_HUB_VIEW_ABI,
818
+ )
819
+ cooldown_seconds = int(
820
+ await market_hub.functions.getPersonalCooldown(
821
+ to_checksum_address(addr)
822
+ ).call()
823
+ )
824
+ cooldown_source = "onchain"
825
+ except Exception as exc: # noqa: BLE001
826
+ logger.warning(f"Failed to read Boros personal cooldown: {exc}")
827
+
828
+ for coll in coll_list:
829
+ if coll.get("tokenId") != int(token_id):
830
+ continue
831
+
832
+ withdrawal = coll.get("withdrawal", {})
833
+ request_time = int(withdrawal.get("lastWithdrawalRequestTime", 0))
834
+ raw_amount = int(withdrawal.get("lastWithdrawalAmount", 0))
835
+ amount = (
836
+ raw_amount / (10 ** int(token_decimals)) if raw_amount else 0.0
837
+ )
838
+
839
+ current_time = int(time.time())
840
+ elapsed = current_time - request_time if request_time > 0 else 0
841
+ if cooldown_seconds is None:
842
+ cooldown_seconds = 3600
843
+ cooldown_source = "default_3600s"
844
+
845
+ withdrawal_status = {
846
+ "amount": amount,
847
+ "request_time": request_time,
848
+ "elapsed_seconds": elapsed,
849
+ "cooldown_seconds": cooldown_seconds,
850
+ "cooldown_source": cooldown_source,
851
+ "can_finalize": elapsed >= cooldown_seconds
852
+ if request_time > 0 and cooldown_seconds is not None
853
+ else False,
854
+ "wait_seconds": max(0, cooldown_seconds - elapsed)
855
+ if request_time > 0 and cooldown_seconds is not None
856
+ else None,
857
+ }
858
+ break
859
+
860
+ if withdrawal_status is None:
861
+ withdrawal_status = {
862
+ "amount": 0,
863
+ "request_time": 0,
864
+ "can_finalize": False,
865
+ }
866
+
867
+ return (
868
+ True,
869
+ {
870
+ "protocol": "boros",
871
+ "chainId": int(self.chain_id),
872
+ "account": addr,
873
+ "collaterals": collaterals,
874
+ "balances": balances,
875
+ "positions": positions,
876
+ "openOrders": orders,
877
+ "withdrawal": withdrawal_status,
878
+ },
879
+ )
880
+ except Exception as exc: # noqa: BLE001
881
+ return False, str(exc)
882
+
883
+ async def get_pending_withdrawal_amount(
884
+ self, token_id: int = 3, *, token_decimals: int = 6
885
+ ) -> tuple[bool, float]:
886
+ try:
887
+ success, collaterals = await self.get_collaterals()
888
+ if not success:
889
+ return False, 0.0
890
+
891
+ amount = self.parse_pending_withdrawal_amount(
892
+ collaterals, token_id=token_id, token_decimals=token_decimals
893
+ )
894
+ return True, amount
895
+ except Exception as e:
896
+ logger.error(f"Failed to get pending withdrawal amount: {e}")
897
+ return False, 0.0
898
+
899
+ @staticmethod
900
+ def parse_pending_withdrawal_amount(
901
+ collaterals_data: dict[str, Any],
902
+ *,
903
+ token_id: int,
904
+ token_decimals: int = 6,
905
+ ) -> float:
906
+ """Parse pending withdrawal amount from collaterals response.
907
+
908
+ Args:
909
+ collaterals_data: Response from get_collaterals().
910
+ token_id: Boros token ID to look for.
911
+ token_decimals: Token decimals for conversion.
912
+
913
+ Returns:
914
+ Pending withdrawal amount in token units (native decimals).
915
+ """
916
+ try:
917
+ coll_list = collaterals_data.get("collaterals", [])
918
+ for coll in coll_list:
919
+ if coll.get("tokenId") != token_id:
920
+ continue
921
+
922
+ # Check withdrawal field (native decimals)
923
+ withdrawal = coll.get("withdrawal", {})
924
+ raw = withdrawal.get("lastWithdrawalAmount", "0")
925
+ if raw and int(raw) > 0:
926
+ return float(raw) / (10**token_decimals)
927
+
928
+ return 0.0
929
+ except Exception as e:
930
+ logger.warning(f"Failed to parse pending withdrawal: {e}")
931
+ return 0.0
932
+
933
+ async def get_withdrawal_status(
934
+ self, token_id: int = 3, *, token_decimals: int = 6
935
+ ) -> tuple[bool, dict[str, Any]]:
936
+ """Get withdrawal status including timing info.
937
+
938
+ Boros withdrawals can have a user-specific cooldown. Prefer on-chain
939
+ cooldown reads when connected to chain, and treat any fallback
940
+ estimate as advisory only.
941
+
942
+ Args:
943
+ token_id: Boros token ID (default 3 = USDT).
944
+ token_decimals: Token decimals for conversion.
945
+
946
+ Returns:
947
+ Tuple of (success, status dict with 'amount', 'request_time', 'can_finalize').
948
+ """
949
+ try:
950
+ success, collaterals = await self.get_collaterals()
951
+ if not success:
952
+ return False, {"error": "Failed to get collaterals"}
953
+
954
+ cooldown_seconds: int | None = None
955
+ cooldown_source = "unknown"
956
+ if self.user_address:
957
+ try:
958
+ async with web3_from_chain_id(self.chain_id) as web3:
959
+ market_hub = web3.eth.contract(
960
+ address=to_checksum_address(BOROS_MARKET_HUB),
961
+ abi=self.BOROS_MARKET_HUB_VIEW_ABI,
962
+ )
963
+ cooldown_seconds = int(
964
+ await market_hub.functions.getPersonalCooldown(
965
+ to_checksum_address(self.user_address)
966
+ ).call()
967
+ )
968
+ cooldown_source = "onchain"
969
+ except Exception as exc:
970
+ logger.warning(f"Failed to read Boros personal cooldown: {exc}")
971
+
972
+ for coll in collaterals.get("collaterals", []):
973
+ if coll.get("tokenId") != token_id:
974
+ continue
975
+
976
+ withdrawal = coll.get("withdrawal", {})
977
+ request_time = int(withdrawal.get("lastWithdrawalRequestTime", 0))
978
+ raw_amount = int(withdrawal.get("lastWithdrawalAmount", 0))
979
+ amount = raw_amount / (10**token_decimals) if raw_amount else 0.0
980
+
981
+ current_time = int(time.time())
982
+ elapsed = current_time - request_time if request_time > 0 else 0
983
+ if cooldown_seconds is None:
984
+ cooldown_seconds = 3600
985
+ cooldown_source = "default_3600s"
986
+
987
+ return True, {
988
+ "amount": amount,
989
+ "request_time": request_time,
990
+ "elapsed_seconds": elapsed,
991
+ "cooldown_seconds": cooldown_seconds,
992
+ "cooldown_source": cooldown_source,
993
+ "can_finalize": elapsed >= cooldown_seconds
994
+ if request_time > 0 and cooldown_seconds is not None
995
+ else False,
996
+ "wait_seconds": max(0, cooldown_seconds - elapsed)
997
+ if request_time > 0 and cooldown_seconds is not None
998
+ else None,
999
+ }
1000
+
1001
+ return True, {"amount": 0, "request_time": 0, "can_finalize": False}
1002
+ except Exception as e:
1003
+ logger.error(f"Failed to get withdrawal status: {e}")
1004
+ return False, {"error": str(e)}
1005
+
1006
+ # ------------------------------------------------------------------ #
1007
+ # Execution Methods #
1008
+ # ------------------------------------------------------------------ #
1009
+
1010
+ async def deposit_to_cross_margin(
1011
+ self,
1012
+ collateral_address: str,
1013
+ amount_wei: int,
1014
+ *,
1015
+ token_id: int,
1016
+ market_id: int,
1017
+ ) -> tuple[bool, dict[str, Any]]:
1018
+ if self.simulation:
1019
+ logger.info(
1020
+ f"[SIMULATION] deposit_to_cross_margin: {amount_wei} wei, "
1021
+ f"token_id={token_id}, market_id={market_id}"
1022
+ )
1023
+ return True, {"status": "simulated", "tx_hash": "0xSIMULATED"}
1024
+
1025
+ try:
1026
+ calldata = await self.boros_client.build_deposit_calldata(
1027
+ token_id=token_id,
1028
+ amount_wei=amount_wei,
1029
+ market_id=market_id,
1030
+ user_address=self.user_address,
1031
+ account_id=0, # Cross margin
1032
+ )
1033
+
1034
+ if not self.sign_callback or not self.user_address:
1035
+ return False, {
1036
+ "error": "sign_callback or user_address not configured",
1037
+ "calldata": calldata,
1038
+ }
1039
+
1040
+ # Approve Boros to pull collateral for deposit.
1041
+ tx_src = self._unwrap_tx_payload(calldata)
1042
+ spender = tx_src.get("to") or calldata.get("to")
1043
+ if not isinstance(spender, str) or not spender:
1044
+ return False, {
1045
+ "error": "Deposit calldata missing spender address",
1046
+ "calldata": calldata,
1047
+ }
1048
+
1049
+ try:
1050
+ approve_tx = await build_approve_transaction(
1051
+ from_address=to_checksum_address(self.user_address),
1052
+ chain_id=int(self.chain_id),
1053
+ token_address=to_checksum_address(collateral_address),
1054
+ spender_address=to_checksum_address(spender),
1055
+ amount=int(amount_wei),
1056
+ )
1057
+ approve_hash = await send_transaction(
1058
+ approve_tx, self.sign_callback, wait_for_receipt=True
1059
+ )
1060
+ approve_res = {"tx_hash": approve_hash}
1061
+ except Exception as e:
1062
+ return False, {
1063
+ "error": f"ERC20 approval failed: {e}",
1064
+ "approve": {"error": str(e)},
1065
+ "calldata": calldata,
1066
+ }
1067
+
1068
+ tx_ok, tx_res = await self._broadcast_calldata(calldata)
1069
+ if not tx_ok:
1070
+ return False, {
1071
+ "error": f"Deposit transaction failed: {tx_res.get('error') or tx_res}",
1072
+ "approve": approve_res,
1073
+ "calldata": calldata,
1074
+ "tx": tx_res,
1075
+ }
1076
+
1077
+ return True, {"status": "ok", "approve": approve_res, "tx": tx_res}
1078
+ except Exception as e:
1079
+ logger.error(f"Failed to deposit to cross margin: {e}")
1080
+ return False, {"error": str(e)}
1081
+
1082
+ async def withdraw_collateral(
1083
+ self,
1084
+ *,
1085
+ token_id: int,
1086
+ amount_native: int | None = None,
1087
+ amount_wei: int | None = None,
1088
+ account_id: int | None = None,
1089
+ ) -> tuple[bool, dict[str, Any]]:
1090
+ """Withdraw collateral from Boros account.
1091
+
1092
+ IMPORTANT: The amount must be in NATIVE token decimals, not 1e18!
1093
+ - For USDT (token_id=3): 6 decimals, so 1 USDT = 1_000_000
1094
+ - For other tokens: check their native decimals
1095
+
1096
+ Args:
1097
+ token_id: Boros token ID.
1098
+ amount_native: Amount in native token decimals (e.g., 6 decimals for USDT).
1099
+ amount_wei: Backwards-compatible alias for amount_native (Boros APIs use
1100
+ "wei" naming even when values are native decimals).
1101
+ account_id: Account ID.
1102
+
1103
+ Returns:
1104
+ Tuple of (success, transaction result).
1105
+ """
1106
+ # Backwards-compat: older callers/tests used amount_wei even though this is
1107
+ # native token decimals. Prefer amount_native going forward.
1108
+ if amount_native is None:
1109
+ if amount_wei is None:
1110
+ raise TypeError(
1111
+ "withdraw_collateral requires amount_native (or amount_wei)"
1112
+ )
1113
+ amount_native = int(amount_wei)
1114
+
1115
+ if self.simulation:
1116
+ logger.info(
1117
+ f"[SIMULATION] withdraw_collateral: {amount_native} native units, token_id={token_id}"
1118
+ )
1119
+ return True, {"status": "simulated", "tx_hash": "0xSIMULATED"}
1120
+
1121
+ try:
1122
+ calldata = await self.boros_client.build_withdraw_calldata(
1123
+ token_id=token_id,
1124
+ amount_wei=amount_native, # API expects native decimals despite param name
1125
+ user_address=self.user_address,
1126
+ account_id=account_id,
1127
+ )
1128
+
1129
+ tx_ok, tx_res = await self._broadcast_calldata(calldata)
1130
+ if not tx_ok:
1131
+ return False, tx_res
1132
+ return True, tx_res
1133
+ except Exception as e:
1134
+ logger.error(f"Failed to withdraw collateral: {e}")
1135
+ return False, {"error": str(e)}
1136
+
1137
+ async def cash_transfer(
1138
+ self,
1139
+ *,
1140
+ market_id: int,
1141
+ amount_wei: int,
1142
+ is_deposit: bool = False,
1143
+ ) -> tuple[bool, dict[str, Any]]:
1144
+ """Transfer cash between isolated and cross margin accounts.
1145
+
1146
+ Semantics:
1147
+ - is_deposit=True: cross -> isolated
1148
+ - is_deposit=False: isolated -> cross
1149
+
1150
+ Notes:
1151
+ - Boros uses 1e18 internal cash units for this call.
1152
+ """
1153
+ if self.simulation:
1154
+ logger.info(
1155
+ f"[SIMULATION] cash_transfer: market={market_id}, amount={amount_wei}, is_deposit={is_deposit}"
1156
+ )
1157
+ return True, {"status": "simulated"}
1158
+
1159
+ try:
1160
+ calldata = await self.boros_client.build_cash_transfer_calldata(
1161
+ market_id=market_id,
1162
+ amount_wei=amount_wei,
1163
+ is_deposit=is_deposit,
1164
+ )
1165
+ logger.debug(f"Boros cash_transfer calldata response: {calldata}")
1166
+
1167
+ tx_ok, tx_res = await self._broadcast_calldata(calldata)
1168
+ logger.debug(f"Boros cash_transfer tx result: ok={tx_ok}, res={tx_res}")
1169
+ if not tx_ok:
1170
+ return False, tx_res
1171
+ logger.info("Boros cash_transfer succeeded (isolated -> cross)")
1172
+ return True, tx_res
1173
+ except Exception as e:
1174
+ logger.error(f"Failed to cash transfer: {e}")
1175
+ return False, {"error": str(e)}
1176
+
1177
+ async def place_rate_order(
1178
+ self,
1179
+ *,
1180
+ market_id: int,
1181
+ token_id: int,
1182
+ size_yu_wei: int,
1183
+ side: str,
1184
+ limit_tick: int | None = None,
1185
+ tif: str = "GTC",
1186
+ slippage: float = 0.05,
1187
+ ) -> tuple[bool, dict[str, Any]]:
1188
+ if self.simulation:
1189
+ logger.info(
1190
+ f"[SIMULATION] place_rate_order: market={market_id}, "
1191
+ f"side={side}, size={size_yu_wei}"
1192
+ )
1193
+ return True, {"status": "simulated", "tx_hash": "0xSIMULATED"}
1194
+
1195
+ try:
1196
+ market_acc = await self._get_market_acc(token_id=token_id)
1197
+
1198
+ if limit_tick is None:
1199
+ limit_tick = await self._pick_limit_tick_for_fill(
1200
+ market_id=market_id, side=side, size_yu_wei=size_yu_wei
1201
+ )
1202
+
1203
+ side_int = 0 if side.lower() in ("long", "buy") else 1
1204
+ tif_int = {"GTC": 0, "IOC": 1, "FOK": 2}.get(tif.upper(), 0)
1205
+
1206
+ calldata = await self.boros_client.build_place_order_calldata(
1207
+ market_acc=market_acc,
1208
+ market_id=market_id,
1209
+ side=side_int,
1210
+ size_wei=size_yu_wei,
1211
+ limit_tick=limit_tick,
1212
+ tif=tif_int,
1213
+ slippage=slippage,
1214
+ )
1215
+
1216
+ if not self.sign_callback:
1217
+ return False, {
1218
+ "error": "sign_callback not configured",
1219
+ "calldata": calldata,
1220
+ }
1221
+
1222
+ tx_ok, tx_res = await self._broadcast_calldata(calldata)
1223
+ if not tx_ok:
1224
+ return False, tx_res
1225
+ return True, tx_res
1226
+ except Exception as e:
1227
+ logger.error(f"Failed to place rate order: {e}")
1228
+ return False, {"error": str(e)}
1229
+
1230
+ async def close_positions_market(
1231
+ self,
1232
+ market_id: int,
1233
+ *,
1234
+ token_id: int = 3,
1235
+ size_yu_wei: int | None = None,
1236
+ ) -> tuple[bool, dict[str, Any]]:
1237
+ if self.simulation:
1238
+ logger.info(f"[SIMULATION] close_positions_market: market={market_id}")
1239
+ return True, {"status": "simulated", "tx_hash": "0xSIMULATED"}
1240
+
1241
+ try:
1242
+ success, positions = await self.get_active_positions(market_id=market_id)
1243
+ if not success or not positions:
1244
+ return True, {"status": "no_position"}
1245
+
1246
+ position = positions[0]
1247
+ pos_side = int(position.get("side", 0))
1248
+ pos_size_wei = int(position.get("sizeWei") or 0)
1249
+
1250
+ if pos_size_wei == 0:
1251
+ return True, {"status": "zero_size"}
1252
+
1253
+ close_size = (
1254
+ abs(int(size_yu_wei)) if size_yu_wei is not None else abs(pos_size_wei)
1255
+ )
1256
+ close_side = 1 if pos_side == 0 else 0
1257
+ close_side_str = "short" if close_side == 1 else "long"
1258
+
1259
+ market_acc = await self._get_market_acc(token_id=token_id)
1260
+ limit_tick = await self._pick_limit_tick_for_fill(
1261
+ market_id=market_id,
1262
+ side=close_side_str,
1263
+ size_yu_wei=close_size,
1264
+ )
1265
+
1266
+ calldata = await self.boros_client.build_close_position_calldata(
1267
+ market_acc=market_acc,
1268
+ market_id=market_id,
1269
+ side=close_side,
1270
+ size_wei=close_size,
1271
+ limit_tick=limit_tick,
1272
+ tif=1, # IOC
1273
+ )
1274
+
1275
+ if not self.sign_callback:
1276
+ return False, {
1277
+ "error": "sign_callback not configured",
1278
+ "calldata": calldata,
1279
+ }
1280
+
1281
+ tx_ok, tx_res = await self._broadcast_calldata(calldata)
1282
+ if not tx_ok:
1283
+ return False, tx_res
1284
+ return True, tx_res
1285
+ except Exception as e:
1286
+ logger.error(f"Failed to close positions: {e}")
1287
+ return False, {"error": str(e)}
1288
+
1289
+ async def cancel_orders(
1290
+ self,
1291
+ *,
1292
+ market_id: int,
1293
+ token_id: int = 3,
1294
+ order_ids: list[str] | None = None,
1295
+ cancel_all: bool = False,
1296
+ ) -> tuple[bool, dict[str, Any]]:
1297
+ if self.simulation:
1298
+ logger.info(
1299
+ f"[SIMULATION] cancel_orders: market={market_id}, cancel_all={cancel_all}"
1300
+ )
1301
+ return True, {"status": "simulated", "tx_hash": "0xSIMULATED"}
1302
+
1303
+ try:
1304
+ market_acc = await self._get_market_acc(token_id=token_id)
1305
+
1306
+ calldata = await self.boros_client.build_cancel_order_calldata(
1307
+ market_acc=market_acc,
1308
+ market_id=market_id,
1309
+ order_ids=order_ids,
1310
+ cancel_all=cancel_all,
1311
+ )
1312
+
1313
+ if not self.sign_callback:
1314
+ return False, {
1315
+ "error": "sign_callback not configured",
1316
+ "calldata": calldata,
1317
+ }
1318
+
1319
+ tx_ok, tx_res = await self._broadcast_calldata(calldata)
1320
+ if not tx_ok:
1321
+ return False, tx_res
1322
+ return True, tx_res
1323
+ except Exception as e:
1324
+ logger.error(f"Failed to cancel orders: {e}")
1325
+ return False, {"error": str(e)}
1326
+
1327
+ async def finalize_vault_withdrawal(
1328
+ self, *, token_id: int, root_address: str | None = None
1329
+ ) -> tuple[bool, dict[str, Any]]:
1330
+ """Finalize a previously requested MarketHub withdrawal.
1331
+
1332
+ This transfers collateral that was previously requested for withdrawal
1333
+ to the root_address (defaults to the user's wallet address).
1334
+
1335
+ Note: This calls the MarketHub contract directly as there's no API endpoint.
1336
+
1337
+ Args:
1338
+ token_id: Boros token ID.
1339
+ root_address: Destination address (defaults to user_address).
1340
+
1341
+ Returns:
1342
+ Tuple of (success, transaction result).
1343
+ """
1344
+ if self.simulation:
1345
+ logger.info(
1346
+ f"[SIMULATION] finalize_vault_withdrawal: token_id={token_id}, "
1347
+ f"root_address={root_address or self.user_address}"
1348
+ )
1349
+ return True, {
1350
+ "status": "simulated",
1351
+ "token_id": token_id,
1352
+ "root_address": root_address or self.user_address,
1353
+ }
1354
+
1355
+ try:
1356
+ dest_address = root_address or self.user_address
1357
+ if not dest_address:
1358
+ return False, {"error": "No destination address configured"}
1359
+
1360
+ if not self.sign_callback:
1361
+ return False, {"error": "sign_callback not configured"}
1362
+
1363
+ # Encode finalizeVaultWithdrawal(address root, uint16 tokenId) directly
1364
+ # Function selector: keccak256("finalizeVaultWithdrawal(address,uint16)")[:4]
1365
+ selector = function_signature_to_4byte_selector(
1366
+ "finalizeVaultWithdrawal(address,uint16)"
1367
+ )
1368
+ params = encode(
1369
+ ["address", "uint16"], [to_checksum_address(dest_address), token_id]
1370
+ )
1371
+ data = "0x" + selector.hex() + params.hex()
1372
+
1373
+ tx = {
1374
+ "chainId": self.chain_id,
1375
+ "from": to_checksum_address(self.user_address),
1376
+ "to": to_checksum_address(BOROS_MARKET_HUB),
1377
+ "data": data,
1378
+ "value": 0,
1379
+ }
1380
+
1381
+ try:
1382
+ tx_hash = await send_transaction(
1383
+ tx, self.sign_callback, wait_for_receipt=True
1384
+ )
1385
+ return True, {"status": "ok", "tx": {"tx_hash": tx_hash}}
1386
+ except Exception as e:
1387
+ return False, {
1388
+ "status": "error",
1389
+ "error": str(e),
1390
+ "tx": {"error": str(e)},
1391
+ }
1392
+ except Exception as e:
1393
+ logger.error(f"Failed to finalize vault withdrawal: {e}")
1394
+ return False, {"error": str(e)}
1395
+
1396
+ # ------------------------------------------------------------------ #
1397
+ # Internal Helpers #
1398
+ # ------------------------------------------------------------------ #
1399
+
1400
+ def _extract_symbol(self, market: dict[str, Any]) -> str:
1401
+ return extract_symbol(market)
1402
+
1403
+ def _extract_underlying(self, market: dict[str, Any]) -> str:
1404
+ return extract_underlying(market)
1405
+
1406
+ def _extract_collateral(self, market: dict[str, Any]) -> str:
1407
+ return extract_collateral(market)
1408
+
1409
+ def _extract_maturity_ts(self, market: dict[str, Any]) -> int | None:
1410
+ return extract_maturity_ts(market)
1411
+
1412
+ def _time_to_maturity_days(self, maturity_ts: int) -> float:
1413
+ return time_to_maturity_days(maturity_ts)
1414
+
1415
+ def _parse_market_position(
1416
+ self,
1417
+ mkt_pos: dict[str, Any],
1418
+ token_id: int | None,
1419
+ is_cross: bool,
1420
+ ) -> dict[str, Any] | None:
1421
+ return parse_market_position(mkt_pos, token_id, is_cross=is_cross)
1422
+
1423
+ async def _get_market_acc(self, token_id: int) -> str:
1424
+ """Get marketAcc from Boros API (collaterals/summary).
1425
+
1426
+ Fetch from the Boros API rather than building locally to match backend expectations.
1427
+
1428
+ Falls back to local construction if API doesn't return marketAcc.
1429
+ """
1430
+ if not self.user_address:
1431
+ raise ValueError("user_address not configured")
1432
+
1433
+ # Try to get marketAcc from API (preferred)
1434
+ try:
1435
+ success, balances = await self.get_account_balances(token_id=token_id)
1436
+ if success and isinstance(balances, dict):
1437
+ # Look for marketAcc in the raw data
1438
+ raw = balances.get("raw", {})
1439
+ for coll in raw.get("collaterals", []):
1440
+ if coll.get("tokenId") == token_id:
1441
+ cross = coll.get("crossPosition") or {}
1442
+ market_acc = cross.get("marketAcc")
1443
+ if market_acc:
1444
+ logger.debug(f"Got marketAcc from API: {market_acc}")
1445
+ return market_acc
1446
+ except Exception as e:
1447
+ logger.debug(
1448
+ f"Failed to get marketAcc from API, falling back to local: {e}"
1449
+ )
1450
+
1451
+ # Fallback: build locally
1452
+ # MarketAcc = address(20) | accountId(1) | tokenId(2) | marketId(3)
1453
+ addr = (
1454
+ self.user_address[2:]
1455
+ if self.user_address.startswith("0x")
1456
+ else self.user_address
1457
+ )
1458
+ account_hex = format(self.account_id, "02x")
1459
+ token_hex = format(token_id, "04x")
1460
+ market_hex = "ffffff" # Cross margin marker
1461
+
1462
+ market_acc = f"0x{addr.lower()}{account_hex}{token_hex}{market_hex}"
1463
+ logger.debug(f"Built marketAcc locally: {market_acc}")
1464
+ return market_acc
1465
+
1466
+ async def _get_tick_step(self, market_id: int) -> int:
1467
+ try:
1468
+ success, mkt = await self.get_market(market_id)
1469
+ if not success:
1470
+ return 1
1471
+ step = (mkt.get("imData") or {}).get("tickStep") or mkt.get("tickStep") or 1
1472
+ return int(step)
1473
+ except Exception:
1474
+ return 1
1475
+
1476
+ async def _pick_limit_tick_for_fill(
1477
+ self,
1478
+ market_id: int,
1479
+ side: str,
1480
+ size_yu_wei: int,
1481
+ max_ia_deviation: int = 50,
1482
+ ) -> int:
1483
+ """Find a limit tick deep enough in the orderbook to fill the order.
1484
+
1485
+ IMPORTANT: The orderbook returns 'ia' (implied APR in bps, e.g., 116 = 1.16%)
1486
+ but Boros API expects 'limitTick' which uses TickMath (nonlinear).
1487
+ We must convert ia -> rate -> limitTick using the market's tickStep.
1488
+
1489
+ For SHORT: walk down the long side (bids) until cumulative size >= order size
1490
+ For LONG: walk up the short side (asks) until cumulative size >= order size
1491
+
1492
+ Args:
1493
+ market_id: Boros market ID
1494
+ side: "short"/"long"
1495
+ size_yu_wei: Order size in wei
1496
+ max_ia_deviation: Max allowed implied APR deviation from best (in bps)
1497
+
1498
+ Returns:
1499
+ limitTick value for Boros API (NOT the same as ia!)
1500
+ """
1501
+ try:
1502
+ success, book = await self.get_orderbook(market_id, tick_size=0.0001)
1503
+ if not success:
1504
+ logger.warning(f"Failed to get orderbook for market {market_id}")
1505
+ return 0
1506
+
1507
+ is_short = side.lower() in ("short", "sell")
1508
+
1509
+ if is_short:
1510
+ # Selling YU -> hit the bids (long side)
1511
+ ia_list = (book.get("long") or {}).get("ia") or []
1512
+ sz_list = (book.get("long") or {}).get("sz") or []
1513
+ else:
1514
+ # Buying YU -> hit the asks (short side)
1515
+ ia_list = (book.get("short") or {}).get("ia") or []
1516
+ sz_list = (book.get("short") or {}).get("sz") or []
1517
+
1518
+ if not ia_list or not sz_list:
1519
+ logger.warning(
1520
+ f"Empty {'long' if is_short else 'short'} side in orderbook for market {market_id}"
1521
+ )
1522
+ return 0
1523
+
1524
+ # Pair implied APR buckets with sizes and sort appropriately
1525
+ levels = list(zip(ia_list, sz_list, strict=False))
1526
+ if is_short:
1527
+ # For sells, go from highest ia (best bid) down
1528
+ levels.sort(key=lambda x: x[0], reverse=True)
1529
+ else:
1530
+ # For buys, go from lowest ia (best ask) up
1531
+ levels.sort(key=lambda x: x[0])
1532
+
1533
+ best_ia = levels[0][0]
1534
+ cumulative = 0
1535
+ chosen_ia = best_ia
1536
+
1537
+ for ia_bps, size_str in levels:
1538
+ # Check if we've deviated too far from best price
1539
+ if is_short:
1540
+ if best_ia - ia_bps > max_ia_deviation:
1541
+ break
1542
+ else:
1543
+ if ia_bps - best_ia > max_ia_deviation:
1544
+ break
1545
+
1546
+ size_wei = int(size_str) if isinstance(size_str, str) else int(size_str)
1547
+ cumulative += size_wei
1548
+ chosen_ia = ia_bps
1549
+
1550
+ if cumulative >= size_yu_wei:
1551
+ break
1552
+
1553
+ # Convert implied APR (bps) -> rate (decimal) -> limitTick
1554
+ tick_step = await self._get_tick_step(market_id)
1555
+ chosen_rate = (
1556
+ float(chosen_ia) / 10_000.0
1557
+ ) # ia is in bps (e.g., 116 = 1.16%)
1558
+
1559
+ # For shorts, round_down to ensure we cross the spread and fill
1560
+ # For longs, round_up (round_down=False) to ensure we cross and fill
1561
+ limit_tick = self.tick_from_rate(
1562
+ chosen_rate, tick_step, round_down=is_short
1563
+ )
1564
+
1565
+ logger.info(
1566
+ f"Boros tick selection: side={side}, chosen_ia={chosen_ia} bps ({chosen_rate * 100:.2f}%), "
1567
+ f"tick_step={tick_step}, limitTick={limit_tick}, "
1568
+ f"verify_rate={self.rate_from_tick(limit_tick, tick_step) * 100:.4f}%"
1569
+ )
1570
+
1571
+ return limit_tick
1572
+ except Exception as e:
1573
+ logger.warning(f"Failed to pick limit tick: {e}")
1574
+ return 0