wayfinder-paths 0.1.6__py3-none-any.whl → 0.1.8__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/balance_adapter/README.md +0 -10
- wayfinder_paths/adapters/balance_adapter/adapter.py +0 -20
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -30
- wayfinder_paths/adapters/brap_adapter/adapter.py +3 -2
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +9 -13
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +14 -7
- wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
- wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
- wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
- wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
- wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
- wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
- wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +7 -6
- wayfinder_paths/adapters/pool_adapter/README.md +3 -28
- wayfinder_paths/adapters/pool_adapter/adapter.py +0 -72
- wayfinder_paths/adapters/pool_adapter/examples.json +0 -43
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +4 -54
- wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -14
- wayfinder_paths/core/adapters/models.py +9 -4
- wayfinder_paths/core/analytics/__init__.py +11 -0
- wayfinder_paths/core/analytics/bootstrap.py +57 -0
- wayfinder_paths/core/analytics/stats.py +48 -0
- wayfinder_paths/core/analytics/test_analytics.py +170 -0
- wayfinder_paths/core/clients/BRAPClient.py +1 -0
- wayfinder_paths/core/clients/LedgerClient.py +2 -7
- wayfinder_paths/core/clients/PoolClient.py +0 -16
- wayfinder_paths/core/clients/WalletClient.py +0 -27
- wayfinder_paths/core/clients/protocols.py +104 -18
- wayfinder_paths/scripts/make_wallets.py +9 -0
- wayfinder_paths/scripts/run_strategy.py +124 -0
- wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
- wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
- wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
- wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
- wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
- wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
- wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +1 -9
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +36 -5
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +367 -278
- wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +204 -7
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/METADATA +32 -3
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/RECORD +50 -27
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
"""PairedFiller - atomic paired spot+perp order execution with imbalance repair."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import binascii
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from decimal import ROUND_DOWN, ROUND_UP, Decimal
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
12
|
+
|
|
13
|
+
from loguru import logger
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from wayfinder_paths.adapters.hyperliquid_adapter.adapter import HyperliquidAdapter
|
|
17
|
+
|
|
18
|
+
MIN_NOTIONAL_USD = 10.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _now_ms() -> int:
|
|
22
|
+
return int(time.time() * 1000)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rand_cloid(prefix: str = "0x") -> str:
|
|
26
|
+
return prefix + binascii.hexlify(os.urandom(16)).decode()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _round_down_units(units: float, step: Decimal) -> float:
|
|
30
|
+
if units <= 0:
|
|
31
|
+
return 0.0
|
|
32
|
+
if step == 0:
|
|
33
|
+
return units
|
|
34
|
+
quantized = Decimal(str(units)) / step
|
|
35
|
+
return float(quantized.to_integral_value(rounding=ROUND_DOWN) * step)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _round_up_units(units: float, step: Decimal) -> float:
|
|
39
|
+
if units <= 0:
|
|
40
|
+
return 0.0
|
|
41
|
+
if step == 0:
|
|
42
|
+
return units
|
|
43
|
+
quantized = Decimal(str(units)) / step
|
|
44
|
+
return float(quantized.to_integral_value(rounding=ROUND_UP) * step)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_oids_and_immediate_fill(
|
|
48
|
+
resp: dict[str, Any],
|
|
49
|
+
) -> tuple[list[int], float, float]:
|
|
50
|
+
"""Extract order IDs and immediate fill info from API response."""
|
|
51
|
+
oids: list[int] = []
|
|
52
|
+
filled_units = 0.0
|
|
53
|
+
filled_notional = 0.0
|
|
54
|
+
|
|
55
|
+
if resp.get("status") != "ok":
|
|
56
|
+
return oids, filled_units, filled_notional
|
|
57
|
+
|
|
58
|
+
data = (resp.get("response") or {}).get("data") or {}
|
|
59
|
+
for status in data.get("statuses", []):
|
|
60
|
+
filled = status.get("filled") or {}
|
|
61
|
+
resting = status.get("resting") or {}
|
|
62
|
+
|
|
63
|
+
for section in (filled, resting):
|
|
64
|
+
oid_val = section.get("oid")
|
|
65
|
+
if oid_val is None:
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
oid_int = int(oid_val)
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
continue
|
|
71
|
+
if oid_int not in oids:
|
|
72
|
+
oids.append(oid_int)
|
|
73
|
+
|
|
74
|
+
size = None
|
|
75
|
+
for key in ("totalSz", "sz", "size", "quantity"):
|
|
76
|
+
val = filled.get(key)
|
|
77
|
+
try:
|
|
78
|
+
size = float(val)
|
|
79
|
+
break
|
|
80
|
+
except (TypeError, ValueError, AttributeError):
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
if size is None or size == 0:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
price = None
|
|
87
|
+
for key in ("avgPx", "px", "price"):
|
|
88
|
+
val = filled.get(key)
|
|
89
|
+
try:
|
|
90
|
+
price = float(val)
|
|
91
|
+
break
|
|
92
|
+
except (TypeError, ValueError, AttributeError):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
filled_units += abs(size)
|
|
96
|
+
if price is not None:
|
|
97
|
+
filled_notional += abs(size) * price
|
|
98
|
+
else:
|
|
99
|
+
notional = None
|
|
100
|
+
for key in ("usdValue", "notional", "value"):
|
|
101
|
+
val = filled.get(key)
|
|
102
|
+
try:
|
|
103
|
+
notional = float(val)
|
|
104
|
+
break
|
|
105
|
+
except (TypeError, ValueError, AttributeError):
|
|
106
|
+
continue
|
|
107
|
+
if notional is not None:
|
|
108
|
+
filled_notional += abs(notional)
|
|
109
|
+
|
|
110
|
+
return oids, filled_units, filled_notional
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class FillConfig:
|
|
115
|
+
"""Configuration for paired order filling."""
|
|
116
|
+
|
|
117
|
+
max_slip_bps: int = 35
|
|
118
|
+
max_chunk_usd: float = 7_500.0
|
|
119
|
+
max_loops: int = 40
|
|
120
|
+
residual_shrink: float = 0.90
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class FillConfirmCfg:
|
|
125
|
+
"""Configuration for fill confirmation polling."""
|
|
126
|
+
|
|
127
|
+
max_status_polls: int = 4
|
|
128
|
+
poll_sleep_s: float = 0.20
|
|
129
|
+
fills_time_early_ms: int = 3_000
|
|
130
|
+
fills_time_late_ms: int = 8_000
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class LegFillResult:
|
|
135
|
+
"""Result of filling a single leg (spot or perp)."""
|
|
136
|
+
|
|
137
|
+
units: float = 0.0
|
|
138
|
+
notional: float = 0.0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class LegSubmitResult:
|
|
143
|
+
"""Result of submitting a leg order."""
|
|
144
|
+
|
|
145
|
+
oids: list[int]
|
|
146
|
+
start_ms: int
|
|
147
|
+
coin_label: str
|
|
148
|
+
immediate_units: float
|
|
149
|
+
immediate_notional: float
|
|
150
|
+
response: dict[str, Any]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class LegConfirmer:
|
|
154
|
+
"""Confirms fill completion for individual legs via polling."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, adapter: HyperliquidAdapter, cfg: FillConfirmCfg):
|
|
157
|
+
self.adapter = adapter
|
|
158
|
+
self.cfg = cfg
|
|
159
|
+
|
|
160
|
+
async def confirm_leg(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
address: str,
|
|
164
|
+
coin_label: str,
|
|
165
|
+
initial_oids: list[int],
|
|
166
|
+
cloid: str | None,
|
|
167
|
+
start_ms: int,
|
|
168
|
+
fallback_units: float = 0.0,
|
|
169
|
+
fallback_notional: float = 0.0,
|
|
170
|
+
) -> LegFillResult:
|
|
171
|
+
"""Confirm that a leg order has been filled."""
|
|
172
|
+
oids: list[int] = list(initial_oids)
|
|
173
|
+
|
|
174
|
+
if not oids and cloid:
|
|
175
|
+
oid = await self._oid_from_cloid(cloid, address)
|
|
176
|
+
if oid is not None:
|
|
177
|
+
oids.append(oid)
|
|
178
|
+
|
|
179
|
+
for _ in range(self.cfg.max_status_polls):
|
|
180
|
+
await self._ensure_not_open(address, oids)
|
|
181
|
+
if oids:
|
|
182
|
+
numeric_or_cloid = oids[0]
|
|
183
|
+
try:
|
|
184
|
+
numeric_or_cloid = int(numeric_or_cloid)
|
|
185
|
+
except (TypeError, ValueError):
|
|
186
|
+
pass
|
|
187
|
+
try:
|
|
188
|
+
success, status = await self.adapter.get_order_status(
|
|
189
|
+
address, numeric_or_cloid
|
|
190
|
+
)
|
|
191
|
+
if success and status.get("status") in {
|
|
192
|
+
"filled",
|
|
193
|
+
"canceled",
|
|
194
|
+
"triggered",
|
|
195
|
+
}:
|
|
196
|
+
break
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
logger.info(f"orderStatus poll failed for oid {oids[0]}: {exc}")
|
|
199
|
+
await asyncio.sleep(self.cfg.poll_sleep_s)
|
|
200
|
+
|
|
201
|
+
result = await self._sum_fills_by_oid_window(address, start_ms, oids)
|
|
202
|
+
if result.units <= 0.0 and fallback_units > 0.0:
|
|
203
|
+
logger.info(
|
|
204
|
+
"Using fallback immediate fill for {}; confirmed units were zero.",
|
|
205
|
+
coin_label,
|
|
206
|
+
)
|
|
207
|
+
return LegFillResult(units=fallback_units, notional=fallback_notional)
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
async def _oid_from_cloid(self, cloid: str, address: str) -> int | None:
|
|
211
|
+
"""Get numeric order ID from client order ID."""
|
|
212
|
+
try:
|
|
213
|
+
success, status = await self.adapter.get_order_status(address, cloid)
|
|
214
|
+
if not success:
|
|
215
|
+
return None
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
logger.warning(f"orderStatus by cloid failed for {cloid}: {exc}")
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
order = status.get("order")
|
|
221
|
+
if not isinstance(order, dict):
|
|
222
|
+
return None
|
|
223
|
+
oid = order.get("oid")
|
|
224
|
+
try:
|
|
225
|
+
return int(oid) if oid is not None else None
|
|
226
|
+
except (TypeError, ValueError):
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
async def _ensure_not_open(self, address: str, oids: list[int]) -> None:
|
|
230
|
+
"""Cancel any orders that are still open."""
|
|
231
|
+
if not oids:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
success, state = await self.adapter.get_user_state(address)
|
|
235
|
+
if not success:
|
|
236
|
+
return
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
logger.info(
|
|
239
|
+
f"Failed to fetch user state while ensuring orders closed: {exc}"
|
|
240
|
+
)
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
if not isinstance(state, dict):
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
open_orders = state.get("openOrders", [])
|
|
247
|
+
flattened = self._flatten_open_orders(open_orders)
|
|
248
|
+
target = {int(o) for o in oids if isinstance(o, int)}
|
|
249
|
+
attempted: set[int] = set()
|
|
250
|
+
|
|
251
|
+
for entry in flattened:
|
|
252
|
+
oid_val = entry.get("oid")
|
|
253
|
+
try:
|
|
254
|
+
oid_int = int(oid_val)
|
|
255
|
+
except (TypeError, ValueError):
|
|
256
|
+
continue
|
|
257
|
+
if oid_int not in target or oid_int in attempted:
|
|
258
|
+
continue
|
|
259
|
+
asset_id = self._resolve_asset_id(entry)
|
|
260
|
+
if asset_id is None:
|
|
261
|
+
continue
|
|
262
|
+
try:
|
|
263
|
+
await self.adapter.cancel_order(asset_id, str(oid_int), address)
|
|
264
|
+
attempted.add(oid_int)
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
logger.info(f"Cancel failed for oid {oid_int}: {exc}")
|
|
267
|
+
|
|
268
|
+
def _resolve_asset_id(self, order: dict[str, Any]) -> int | None:
|
|
269
|
+
"""Resolve asset ID from order info."""
|
|
270
|
+
coin = order.get("coin")
|
|
271
|
+
if not isinstance(coin, str):
|
|
272
|
+
return None
|
|
273
|
+
is_perp = "/" not in coin
|
|
274
|
+
try:
|
|
275
|
+
if is_perp:
|
|
276
|
+
return self.adapter.coin_to_asset.get(coin)
|
|
277
|
+
# For spot, would need spot meta lookup
|
|
278
|
+
return None
|
|
279
|
+
except Exception as exc:
|
|
280
|
+
logger.info(f"Failed to resolve asset id for {coin}: {exc}")
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
def _flatten_open_orders(self, obj: Any) -> list[dict[str, Any]]:
|
|
284
|
+
"""Flatten nested order structures into a flat list."""
|
|
285
|
+
results: list[dict[str, Any]] = []
|
|
286
|
+
|
|
287
|
+
def walk(node: Any, coin_ctx: str | None = None) -> None:
|
|
288
|
+
if isinstance(node, dict):
|
|
289
|
+
coin_ctx = node.get("coin", coin_ctx)
|
|
290
|
+
oid = node.get("oid")
|
|
291
|
+
if oid is not None:
|
|
292
|
+
enriched = dict(node)
|
|
293
|
+
if "coin" not in enriched and coin_ctx:
|
|
294
|
+
enriched["coin"] = coin_ctx
|
|
295
|
+
results.append(enriched)
|
|
296
|
+
resting = node.get("resting")
|
|
297
|
+
if isinstance(resting, dict):
|
|
298
|
+
enriched = dict(resting)
|
|
299
|
+
if "coin" not in enriched and coin_ctx:
|
|
300
|
+
enriched["coin"] = coin_ctx
|
|
301
|
+
results.append(enriched)
|
|
302
|
+
for value in node.values():
|
|
303
|
+
if isinstance(value, (dict, list)):
|
|
304
|
+
walk(value, coin_ctx)
|
|
305
|
+
elif isinstance(node, list):
|
|
306
|
+
for item in node:
|
|
307
|
+
walk(item, coin_ctx)
|
|
308
|
+
|
|
309
|
+
walk(obj)
|
|
310
|
+
return results
|
|
311
|
+
|
|
312
|
+
async def _sum_fills_by_oid_window(
|
|
313
|
+
self,
|
|
314
|
+
address: str,
|
|
315
|
+
start_ms: int,
|
|
316
|
+
oids: list[int],
|
|
317
|
+
) -> LegFillResult:
|
|
318
|
+
"""Sum fills matching order IDs within time window."""
|
|
319
|
+
if not oids:
|
|
320
|
+
return LegFillResult()
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
success, raw_fills = await self.adapter.get_user_fills(address)
|
|
324
|
+
if not success:
|
|
325
|
+
return LegFillResult()
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
logger.warning(f"Failed to fetch user fills: {exc}")
|
|
328
|
+
return LegFillResult()
|
|
329
|
+
|
|
330
|
+
records = self._to_records(raw_fills)
|
|
331
|
+
if not records:
|
|
332
|
+
return LegFillResult()
|
|
333
|
+
|
|
334
|
+
early = self.cfg.fills_time_early_ms + 2000
|
|
335
|
+
late = self.cfg.fills_time_late_ms + 6000
|
|
336
|
+
t0 = start_ms - early
|
|
337
|
+
t1 = start_ms + late
|
|
338
|
+
oid_strs = {str(o) for o in oids if o is not None}
|
|
339
|
+
|
|
340
|
+
total_units = 0.0
|
|
341
|
+
total_notional = 0.0
|
|
342
|
+
|
|
343
|
+
for row in records:
|
|
344
|
+
time_val = row.get("time")
|
|
345
|
+
try:
|
|
346
|
+
time_int = int(time_val)
|
|
347
|
+
except (TypeError, ValueError):
|
|
348
|
+
time_int = None
|
|
349
|
+
|
|
350
|
+
if time_int is not None and (time_int < t0 or time_int > t1):
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
oid_val = row.get("oid")
|
|
354
|
+
if oid_val is None or str(oid_val) not in oid_strs:
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
size = None
|
|
358
|
+
for key in ("sz", "size", "quantity", "totalSz"):
|
|
359
|
+
val = row.get(key)
|
|
360
|
+
try:
|
|
361
|
+
size = float(val)
|
|
362
|
+
break
|
|
363
|
+
except (TypeError, ValueError):
|
|
364
|
+
continue
|
|
365
|
+
if size is None:
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
price = None
|
|
369
|
+
for key in ("px", "price", "avgPx"):
|
|
370
|
+
val = row.get(key)
|
|
371
|
+
try:
|
|
372
|
+
price = float(val)
|
|
373
|
+
break
|
|
374
|
+
except (TypeError, ValueError):
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
total_units += abs(size)
|
|
378
|
+
if price is not None:
|
|
379
|
+
total_notional += abs(size) * price
|
|
380
|
+
else:
|
|
381
|
+
notional = None
|
|
382
|
+
for key in ("usdValue", "notional", "value"):
|
|
383
|
+
val = row.get(key)
|
|
384
|
+
try:
|
|
385
|
+
notional = float(val)
|
|
386
|
+
break
|
|
387
|
+
except (TypeError, ValueError):
|
|
388
|
+
continue
|
|
389
|
+
if notional is not None:
|
|
390
|
+
total_notional += abs(notional)
|
|
391
|
+
|
|
392
|
+
return LegFillResult(units=total_units, notional=total_notional)
|
|
393
|
+
|
|
394
|
+
@staticmethod
|
|
395
|
+
def _to_records(data: Any) -> list[dict[str, Any]]:
|
|
396
|
+
"""Convert various data formats to list of dicts."""
|
|
397
|
+
if data is None:
|
|
398
|
+
return []
|
|
399
|
+
if hasattr(data, "to_dict"):
|
|
400
|
+
try:
|
|
401
|
+
records = data.to_dict("records")
|
|
402
|
+
except Exception:
|
|
403
|
+
records = []
|
|
404
|
+
return [r for r in records if isinstance(r, dict)]
|
|
405
|
+
if isinstance(data, list):
|
|
406
|
+
return [r for r in data if isinstance(r, dict)]
|
|
407
|
+
return []
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class PairedFiller:
|
|
411
|
+
"""
|
|
412
|
+
Executes atomic paired spot+perp orders with imbalance repair.
|
|
413
|
+
|
|
414
|
+
Handles:
|
|
415
|
+
- Chunking large orders into $7,500 pieces
|
|
416
|
+
- Parallel execution of spot and perp legs
|
|
417
|
+
- Imbalance repair if one leg fills more than the other
|
|
418
|
+
- Rollback on failure
|
|
419
|
+
- Fill confirmation via polling
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
def __init__(
|
|
423
|
+
self,
|
|
424
|
+
adapter: HyperliquidAdapter,
|
|
425
|
+
address: str,
|
|
426
|
+
cfg: FillConfig | None = None,
|
|
427
|
+
confirm_cfg: FillConfirmCfg | None = None,
|
|
428
|
+
):
|
|
429
|
+
self.adapter = adapter
|
|
430
|
+
self.address = address
|
|
431
|
+
self.cfg = cfg or FillConfig()
|
|
432
|
+
self.confirm_cfg = confirm_cfg or FillConfirmCfg()
|
|
433
|
+
self.confirmer = LegConfirmer(adapter, self.confirm_cfg)
|
|
434
|
+
|
|
435
|
+
async def fill_pair_units(
|
|
436
|
+
self,
|
|
437
|
+
coin: str,
|
|
438
|
+
spot_asset_id: int,
|
|
439
|
+
perp_asset_id: int,
|
|
440
|
+
total_units: float,
|
|
441
|
+
direction: Literal[
|
|
442
|
+
"long_spot_short_perp", "short_spot_long_perp"
|
|
443
|
+
] = "long_spot_short_perp",
|
|
444
|
+
builder_fee: dict[str, Any] | None = None,
|
|
445
|
+
) -> tuple[
|
|
446
|
+
float,
|
|
447
|
+
float,
|
|
448
|
+
float,
|
|
449
|
+
float,
|
|
450
|
+
list[dict[str, Any]],
|
|
451
|
+
list[dict[str, Any]],
|
|
452
|
+
]:
|
|
453
|
+
"""
|
|
454
|
+
Fill paired spot+perp positions atomically.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
coin: Asset symbol (e.g., "ETH")
|
|
458
|
+
spot_asset_id: Spot asset ID (>= 10000)
|
|
459
|
+
perp_asset_id: Perpetual asset ID (< 10000)
|
|
460
|
+
total_units: Total units to fill
|
|
461
|
+
direction: "long_spot_short_perp" or "short_spot_long_perp"
|
|
462
|
+
builder_fee: Optional builder fee configuration
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
Tuple of (spot_units, perp_units, spot_notional, perp_notional,
|
|
466
|
+
spot_pointers, perp_pointers)
|
|
467
|
+
"""
|
|
468
|
+
step = self._common_step(spot_asset_id, perp_asset_id)
|
|
469
|
+
remaining = _round_down_units(total_units, step)
|
|
470
|
+
if remaining <= 0:
|
|
471
|
+
return 0.0, 0.0, 0.0, 0.0, [], []
|
|
472
|
+
|
|
473
|
+
spot_is_buy = direction == "long_spot_short_perp"
|
|
474
|
+
perp_is_buy = direction == "short_spot_long_perp"
|
|
475
|
+
slip_bps = self.cfg.max_slip_bps
|
|
476
|
+
slip_fraction = slip_bps / 10_000
|
|
477
|
+
|
|
478
|
+
success, mids = await self.adapter.get_all_mid_prices()
|
|
479
|
+
if not success:
|
|
480
|
+
raise ValueError("Cannot fetch mid prices")
|
|
481
|
+
mid_price = float(mids.get(coin, 0.0))
|
|
482
|
+
if mid_price <= 0:
|
|
483
|
+
raise ValueError(f"Cannot determine mid price for {coin}")
|
|
484
|
+
|
|
485
|
+
max_chunk_units = _round_down_units(self.cfg.max_chunk_usd / mid_price, step)
|
|
486
|
+
if max_chunk_units <= 0:
|
|
487
|
+
max_chunk_units = float(step)
|
|
488
|
+
min_units = self._min_units_for_notional(mid_price, step)
|
|
489
|
+
if max_chunk_units < min_units:
|
|
490
|
+
max_chunk_units = min_units
|
|
491
|
+
|
|
492
|
+
loops = 0
|
|
493
|
+
delta_units = 0.0
|
|
494
|
+
total_spot = 0.0
|
|
495
|
+
total_perp = 0.0
|
|
496
|
+
total_spot_notional = 0.0
|
|
497
|
+
total_perp_notional = 0.0
|
|
498
|
+
spot_pointers: list = []
|
|
499
|
+
perp_pointers: list = []
|
|
500
|
+
step_float = float(step)
|
|
501
|
+
|
|
502
|
+
while loops < self.cfg.max_loops and (
|
|
503
|
+
remaining > 0 or abs(delta_units) >= step_float
|
|
504
|
+
):
|
|
505
|
+
# Handle imbalance repair
|
|
506
|
+
if abs(delta_units) >= step_float:
|
|
507
|
+
loops += 1
|
|
508
|
+
fix_units = _round_down_units(abs(delta_units), step)
|
|
509
|
+
if fix_units <= 0:
|
|
510
|
+
delta_units = 0.0
|
|
511
|
+
continue
|
|
512
|
+
if fix_units < min_units:
|
|
513
|
+
logger.warning(
|
|
514
|
+
f"Remaining imbalance {delta_units:.4f} {coin} below venue's minimum; leaving residual."
|
|
515
|
+
)
|
|
516
|
+
break
|
|
517
|
+
|
|
518
|
+
if delta_units > 0:
|
|
519
|
+
# More spot than perp - need more perp
|
|
520
|
+
cl_fix = _rand_cloid()
|
|
521
|
+
perp_submit = await self._ioc_leg(
|
|
522
|
+
is_spot=False,
|
|
523
|
+
asset_id=perp_asset_id,
|
|
524
|
+
coin=coin,
|
|
525
|
+
side_is_buy=perp_is_buy,
|
|
526
|
+
units=fix_units,
|
|
527
|
+
slip_fraction=slip_fraction,
|
|
528
|
+
cloid=cl_fix,
|
|
529
|
+
builder_fee=builder_fee,
|
|
530
|
+
)
|
|
531
|
+
perp_fix = await self.confirmer.confirm_leg(
|
|
532
|
+
address=self.address,
|
|
533
|
+
coin_label=perp_submit.coin_label,
|
|
534
|
+
initial_oids=perp_submit.oids,
|
|
535
|
+
cloid=cl_fix,
|
|
536
|
+
start_ms=perp_submit.start_ms,
|
|
537
|
+
fallback_units=perp_submit.immediate_units,
|
|
538
|
+
fallback_notional=perp_submit.immediate_notional,
|
|
539
|
+
)
|
|
540
|
+
if perp_fix.units <= 0:
|
|
541
|
+
logger.warning(
|
|
542
|
+
f"Perp repair for {coin} did not fill; aborting paired filler."
|
|
543
|
+
)
|
|
544
|
+
break
|
|
545
|
+
total_perp += perp_fix.units
|
|
546
|
+
total_perp_notional += perp_fix.notional
|
|
547
|
+
pointer = self._build_order_pointer(
|
|
548
|
+
perp_submit.response,
|
|
549
|
+
reason="hyperliquid_perp_repair",
|
|
550
|
+
metadata={
|
|
551
|
+
"asset_id": perp_asset_id,
|
|
552
|
+
"size": perp_fix.units,
|
|
553
|
+
"asset_name": coin,
|
|
554
|
+
"client_id": cl_fix,
|
|
555
|
+
},
|
|
556
|
+
)
|
|
557
|
+
if pointer:
|
|
558
|
+
perp_pointers.append(pointer)
|
|
559
|
+
delta_units -= perp_fix.units
|
|
560
|
+
else:
|
|
561
|
+
# More perp than spot - need more spot
|
|
562
|
+
max_units_by_cash = fix_units
|
|
563
|
+
if spot_is_buy:
|
|
564
|
+
max_units_by_cash = await self._max_spot_units(
|
|
565
|
+
fix_units,
|
|
566
|
+
mid_price,
|
|
567
|
+
slip_fraction,
|
|
568
|
+
step,
|
|
569
|
+
min_units,
|
|
570
|
+
)
|
|
571
|
+
if max_units_by_cash <= 0:
|
|
572
|
+
logger.warning(
|
|
573
|
+
f"Skipping repair buy for {coin}: insufficient USDC (need {fix_units:.4f} units)."
|
|
574
|
+
)
|
|
575
|
+
break
|
|
576
|
+
fix_units = min(fix_units, max_units_by_cash)
|
|
577
|
+
if fix_units < min_units:
|
|
578
|
+
logger.warning(
|
|
579
|
+
f"Repair size for {coin} below venue minimum after cash check; leaving residual."
|
|
580
|
+
)
|
|
581
|
+
break
|
|
582
|
+
if fix_units <= 0:
|
|
583
|
+
break
|
|
584
|
+
cl_fix = _rand_cloid()
|
|
585
|
+
spot_submit = await self._ioc_leg(
|
|
586
|
+
is_spot=True,
|
|
587
|
+
asset_id=spot_asset_id,
|
|
588
|
+
coin=coin,
|
|
589
|
+
side_is_buy=spot_is_buy,
|
|
590
|
+
units=fix_units,
|
|
591
|
+
slip_fraction=slip_fraction,
|
|
592
|
+
cloid=cl_fix,
|
|
593
|
+
builder_fee=builder_fee,
|
|
594
|
+
)
|
|
595
|
+
spot_fix = await self.confirmer.confirm_leg(
|
|
596
|
+
address=self.address,
|
|
597
|
+
coin_label=spot_submit.coin_label,
|
|
598
|
+
initial_oids=spot_submit.oids,
|
|
599
|
+
cloid=cl_fix,
|
|
600
|
+
start_ms=spot_submit.start_ms,
|
|
601
|
+
fallback_units=spot_submit.immediate_units,
|
|
602
|
+
fallback_notional=spot_submit.immediate_notional,
|
|
603
|
+
)
|
|
604
|
+
if spot_fix.units <= 0:
|
|
605
|
+
logger.warning(
|
|
606
|
+
f"Spot repair for {coin} did not fill; aborting paired filler."
|
|
607
|
+
)
|
|
608
|
+
break
|
|
609
|
+
total_spot += spot_fix.units
|
|
610
|
+
total_spot_notional += spot_fix.notional
|
|
611
|
+
delta_units += spot_fix.units
|
|
612
|
+
pointer = self._build_order_pointer(
|
|
613
|
+
spot_submit.response,
|
|
614
|
+
reason="hyperliquid_spot_repair",
|
|
615
|
+
metadata={
|
|
616
|
+
"asset_id": spot_asset_id,
|
|
617
|
+
"size": spot_fix.units,
|
|
618
|
+
"asset_name": coin,
|
|
619
|
+
"client_id": cl_fix,
|
|
620
|
+
},
|
|
621
|
+
)
|
|
622
|
+
if pointer:
|
|
623
|
+
spot_pointers.append(pointer)
|
|
624
|
+
|
|
625
|
+
delta_units = float(Decimal(str(delta_units)))
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
# Main fill loop
|
|
629
|
+
loops += 1
|
|
630
|
+
chunk = min(remaining, max_chunk_units)
|
|
631
|
+
chunk = max(chunk, float(step))
|
|
632
|
+
chunk = _round_down_units(chunk, step)
|
|
633
|
+
if chunk <= 0:
|
|
634
|
+
break
|
|
635
|
+
|
|
636
|
+
is_residual = (remaining < max_chunk_units) and (loops > 1)
|
|
637
|
+
|
|
638
|
+
if is_residual:
|
|
639
|
+
shrink_factor = self.cfg.residual_shrink
|
|
640
|
+
if shrink_factor <= 0.0:
|
|
641
|
+
shrink_factor = 0.90
|
|
642
|
+
|
|
643
|
+
if shrink_factor < 1.0:
|
|
644
|
+
shrunk_chunk = _round_down_units(chunk * shrink_factor, step)
|
|
645
|
+
if shrunk_chunk < min_units:
|
|
646
|
+
logger.warning(
|
|
647
|
+
"Residual chunk {:.6f} {} shrunk to {:.6f} is below min_units {:.6f}; skipping residual and ending fill loop.",
|
|
648
|
+
chunk,
|
|
649
|
+
coin,
|
|
650
|
+
shrunk_chunk,
|
|
651
|
+
min_units,
|
|
652
|
+
)
|
|
653
|
+
break
|
|
654
|
+
logger.debug(
|
|
655
|
+
"Using residual chunk shrink for {}: remaining={:.6f}, chunk={:.6f} -> {:.6f}",
|
|
656
|
+
coin,
|
|
657
|
+
remaining,
|
|
658
|
+
chunk,
|
|
659
|
+
shrunk_chunk,
|
|
660
|
+
)
|
|
661
|
+
chunk = shrunk_chunk
|
|
662
|
+
|
|
663
|
+
spot_valid = self.adapter.get_valid_order_size(spot_asset_id, chunk)
|
|
664
|
+
perp_valid = self.adapter.get_valid_order_size(perp_asset_id, chunk)
|
|
665
|
+
chunk = _round_down_units(min(spot_valid, perp_valid), step)
|
|
666
|
+
|
|
667
|
+
if chunk <= 0:
|
|
668
|
+
logger.warning(
|
|
669
|
+
"Chunk became non-positive after venue clipping for {}; aborting fill loop.",
|
|
670
|
+
coin,
|
|
671
|
+
)
|
|
672
|
+
break
|
|
673
|
+
|
|
674
|
+
if chunk < min_units:
|
|
675
|
+
logger.warning(
|
|
676
|
+
"Chunk invalid after venue clipping: {:.6f} {} < min_units {:.6f}; aborting fill loop.",
|
|
677
|
+
chunk,
|
|
678
|
+
coin,
|
|
679
|
+
min_units,
|
|
680
|
+
)
|
|
681
|
+
break
|
|
682
|
+
|
|
683
|
+
if spot_is_buy:
|
|
684
|
+
cash_limited_units = await self._max_spot_units(
|
|
685
|
+
chunk,
|
|
686
|
+
mid_price,
|
|
687
|
+
slip_fraction,
|
|
688
|
+
step,
|
|
689
|
+
min_units,
|
|
690
|
+
)
|
|
691
|
+
if cash_limited_units <= 0:
|
|
692
|
+
logger.warning(
|
|
693
|
+
f"Insufficient USDC available to open spot leg for {coin}; aborting fill loop."
|
|
694
|
+
)
|
|
695
|
+
break
|
|
696
|
+
if cash_limited_units < chunk:
|
|
697
|
+
chunk = cash_limited_units
|
|
698
|
+
|
|
699
|
+
cl_spot = _rand_cloid()
|
|
700
|
+
cl_perp = _rand_cloid()
|
|
701
|
+
|
|
702
|
+
# Execute spot and perp legs in parallel
|
|
703
|
+
spot_task = asyncio.create_task(
|
|
704
|
+
self._ioc_leg(
|
|
705
|
+
is_spot=True,
|
|
706
|
+
asset_id=spot_asset_id,
|
|
707
|
+
coin=coin,
|
|
708
|
+
side_is_buy=spot_is_buy,
|
|
709
|
+
units=chunk,
|
|
710
|
+
slip_fraction=slip_fraction,
|
|
711
|
+
cloid=cl_spot,
|
|
712
|
+
builder_fee=builder_fee,
|
|
713
|
+
)
|
|
714
|
+
)
|
|
715
|
+
perp_task = asyncio.create_task(
|
|
716
|
+
self._ioc_leg(
|
|
717
|
+
is_spot=False,
|
|
718
|
+
asset_id=perp_asset_id,
|
|
719
|
+
coin=coin,
|
|
720
|
+
side_is_buy=perp_is_buy,
|
|
721
|
+
units=chunk,
|
|
722
|
+
slip_fraction=slip_fraction,
|
|
723
|
+
cloid=cl_perp,
|
|
724
|
+
builder_fee=builder_fee,
|
|
725
|
+
)
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
spot_submit, perp_submit = await asyncio.gather(spot_task, perp_task)
|
|
729
|
+
start_ms = min(spot_submit.start_ms, perp_submit.start_ms)
|
|
730
|
+
|
|
731
|
+
spot_fill = await self.confirmer.confirm_leg(
|
|
732
|
+
address=self.address,
|
|
733
|
+
coin_label=spot_submit.coin_label,
|
|
734
|
+
initial_oids=spot_submit.oids,
|
|
735
|
+
cloid=cl_spot,
|
|
736
|
+
start_ms=start_ms,
|
|
737
|
+
fallback_units=spot_submit.immediate_units,
|
|
738
|
+
fallback_notional=spot_submit.immediate_notional,
|
|
739
|
+
)
|
|
740
|
+
perp_fill = await self.confirmer.confirm_leg(
|
|
741
|
+
address=self.address,
|
|
742
|
+
coin_label=perp_submit.coin_label,
|
|
743
|
+
initial_oids=perp_submit.oids,
|
|
744
|
+
cloid=cl_perp,
|
|
745
|
+
start_ms=start_ms,
|
|
746
|
+
fallback_units=perp_submit.immediate_units,
|
|
747
|
+
fallback_notional=perp_submit.immediate_notional,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
if spot_fill.units <= 0.0 and perp_fill.units <= 0.0:
|
|
751
|
+
logger.warning("Paired filler made no progress for {}; aborting.", coin)
|
|
752
|
+
break
|
|
753
|
+
|
|
754
|
+
# Handle margin rejection - rollback spot if perp rejected
|
|
755
|
+
if (
|
|
756
|
+
spot_fill.units > 0.0
|
|
757
|
+
and perp_fill.units <= 0.0
|
|
758
|
+
and self._is_margin_rejected(perp_submit.response)
|
|
759
|
+
):
|
|
760
|
+
logger.warning(
|
|
761
|
+
"Perp leg marginRejected for {}; attempting to roll back spot leg of {:.6f} units.",
|
|
762
|
+
coin,
|
|
763
|
+
spot_fill.units,
|
|
764
|
+
)
|
|
765
|
+
try:
|
|
766
|
+
rollback_cl = _rand_cloid()
|
|
767
|
+
rollback_submit = await self._ioc_leg(
|
|
768
|
+
is_spot=True,
|
|
769
|
+
asset_id=spot_asset_id,
|
|
770
|
+
coin=coin,
|
|
771
|
+
side_is_buy=not spot_is_buy,
|
|
772
|
+
units=spot_fill.units,
|
|
773
|
+
slip_fraction=slip_fraction,
|
|
774
|
+
cloid=rollback_cl,
|
|
775
|
+
builder_fee=builder_fee,
|
|
776
|
+
)
|
|
777
|
+
rollback_fill = await self.confirmer.confirm_leg(
|
|
778
|
+
address=self.address,
|
|
779
|
+
coin_label=rollback_submit.coin_label,
|
|
780
|
+
initial_oids=rollback_submit.oids,
|
|
781
|
+
cloid=rollback_cl,
|
|
782
|
+
start_ms=rollback_submit.start_ms,
|
|
783
|
+
fallback_units=rollback_submit.immediate_units,
|
|
784
|
+
fallback_notional=rollback_submit.immediate_notional,
|
|
785
|
+
)
|
|
786
|
+
logger.warning(
|
|
787
|
+
"Rollback for {} completed: {:.6f} units (requested {:.6f}).",
|
|
788
|
+
coin,
|
|
789
|
+
rollback_fill.units,
|
|
790
|
+
spot_fill.units,
|
|
791
|
+
)
|
|
792
|
+
except Exception as exc:
|
|
793
|
+
logger.error("Rollback spot leg for {} failed: {}", coin, exc)
|
|
794
|
+
|
|
795
|
+
break
|
|
796
|
+
|
|
797
|
+
# Handle spot failure - rollback perp if spot failed
|
|
798
|
+
if (
|
|
799
|
+
perp_fill.units > 0.0
|
|
800
|
+
and spot_fill.units <= 0.0
|
|
801
|
+
and self._is_errorish(spot_submit.response)
|
|
802
|
+
):
|
|
803
|
+
logger.warning(
|
|
804
|
+
"Spot leg failed but perp filled for {}; rolling back {:.6f} units.",
|
|
805
|
+
coin,
|
|
806
|
+
perp_fill.units,
|
|
807
|
+
)
|
|
808
|
+
try:
|
|
809
|
+
rollback_cl = _rand_cloid()
|
|
810
|
+
perp_rollback = await self._ioc_leg(
|
|
811
|
+
is_spot=False,
|
|
812
|
+
asset_id=perp_asset_id,
|
|
813
|
+
coin=coin,
|
|
814
|
+
side_is_buy=not perp_is_buy,
|
|
815
|
+
units=perp_fill.units,
|
|
816
|
+
slip_fraction=slip_fraction,
|
|
817
|
+
cloid=rollback_cl,
|
|
818
|
+
builder_fee=builder_fee,
|
|
819
|
+
)
|
|
820
|
+
rollback_fill = await self.confirmer.confirm_leg(
|
|
821
|
+
address=self.address,
|
|
822
|
+
coin_label=perp_rollback.coin_label,
|
|
823
|
+
initial_oids=perp_rollback.oids,
|
|
824
|
+
cloid=rollback_cl,
|
|
825
|
+
start_ms=perp_rollback.start_ms,
|
|
826
|
+
fallback_units=perp_rollback.immediate_units,
|
|
827
|
+
fallback_notional=perp_rollback.immediate_notional,
|
|
828
|
+
)
|
|
829
|
+
logger.warning(
|
|
830
|
+
"Perp rollback for {} completed: {:.6f} units (requested {:.6f}).",
|
|
831
|
+
coin,
|
|
832
|
+
rollback_fill.units,
|
|
833
|
+
perp_fill.units,
|
|
834
|
+
)
|
|
835
|
+
except Exception as exc:
|
|
836
|
+
logger.error("Perp rollback for {} failed: {}", coin, exc)
|
|
837
|
+
|
|
838
|
+
break
|
|
839
|
+
|
|
840
|
+
total_spot += spot_fill.units
|
|
841
|
+
total_perp += perp_fill.units
|
|
842
|
+
total_spot_notional += spot_fill.notional
|
|
843
|
+
total_perp_notional += perp_fill.notional
|
|
844
|
+
|
|
845
|
+
spot_pointer = self._build_order_pointer(
|
|
846
|
+
spot_submit.response,
|
|
847
|
+
reason="hyperliquid_spot_leg",
|
|
848
|
+
metadata={
|
|
849
|
+
"asset_id": spot_asset_id,
|
|
850
|
+
"size": spot_fill.units,
|
|
851
|
+
"asset_name": coin,
|
|
852
|
+
"client_id": cl_spot,
|
|
853
|
+
},
|
|
854
|
+
)
|
|
855
|
+
if spot_pointer:
|
|
856
|
+
spot_pointers.append(spot_pointer)
|
|
857
|
+
|
|
858
|
+
perp_pointer = self._build_order_pointer(
|
|
859
|
+
perp_submit.response,
|
|
860
|
+
reason="hyperliquid_perp_leg",
|
|
861
|
+
metadata={
|
|
862
|
+
"asset_id": perp_asset_id,
|
|
863
|
+
"size": perp_fill.units,
|
|
864
|
+
"asset_name": coin,
|
|
865
|
+
"client_id": cl_perp,
|
|
866
|
+
},
|
|
867
|
+
)
|
|
868
|
+
if perp_pointer:
|
|
869
|
+
perp_pointers.append(perp_pointer)
|
|
870
|
+
|
|
871
|
+
progressed = min(spot_fill.units, perp_fill.units)
|
|
872
|
+
remaining = max(0.0, remaining - progressed)
|
|
873
|
+
remaining = _round_down_units(remaining, step)
|
|
874
|
+
|
|
875
|
+
delta_units += spot_fill.units - perp_fill.units
|
|
876
|
+
delta_units = float(Decimal(str(delta_units)))
|
|
877
|
+
|
|
878
|
+
if abs(delta_units) >= step_float:
|
|
879
|
+
logger.warning(
|
|
880
|
+
"Residual mismatch after repairs for {}: spot={} perp={}",
|
|
881
|
+
coin,
|
|
882
|
+
total_spot,
|
|
883
|
+
total_perp,
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
return (
|
|
887
|
+
total_spot,
|
|
888
|
+
total_perp,
|
|
889
|
+
total_spot_notional,
|
|
890
|
+
total_perp_notional,
|
|
891
|
+
spot_pointers,
|
|
892
|
+
perp_pointers,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
@staticmethod
|
|
896
|
+
def _is_margin_rejected(resp: dict[str, Any]) -> bool:
|
|
897
|
+
"""Detect Hyperliquid margin rejection responses."""
|
|
898
|
+
if not isinstance(resp, dict):
|
|
899
|
+
return False
|
|
900
|
+
|
|
901
|
+
top_status = str(resp.get("status", "")).lower()
|
|
902
|
+
if "margin" in top_status and "reject" in top_status:
|
|
903
|
+
return True
|
|
904
|
+
|
|
905
|
+
order = resp.get("order")
|
|
906
|
+
if isinstance(order, dict):
|
|
907
|
+
inner_status = str(order.get("status", "")).lower()
|
|
908
|
+
if "margin" in inner_status and "reject" in inner_status:
|
|
909
|
+
return True
|
|
910
|
+
|
|
911
|
+
return False
|
|
912
|
+
|
|
913
|
+
@staticmethod
|
|
914
|
+
def _is_errorish(resp: dict[str, Any]) -> bool:
|
|
915
|
+
"""Best-effort detection for failed API responses."""
|
|
916
|
+
if not isinstance(resp, dict):
|
|
917
|
+
return True
|
|
918
|
+
|
|
919
|
+
ok_statuses = {"ok", "filled"}
|
|
920
|
+
status = str(resp.get("status", "")).lower()
|
|
921
|
+
if status and status not in ok_statuses:
|
|
922
|
+
return True
|
|
923
|
+
|
|
924
|
+
order = resp.get("order")
|
|
925
|
+
if isinstance(order, dict):
|
|
926
|
+
inner_status = str(order.get("status", "")).lower()
|
|
927
|
+
if inner_status and inner_status not in ok_statuses.union({"resting"}):
|
|
928
|
+
return True
|
|
929
|
+
|
|
930
|
+
return False
|
|
931
|
+
|
|
932
|
+
async def _ioc_leg(
|
|
933
|
+
self,
|
|
934
|
+
*,
|
|
935
|
+
is_spot: bool,
|
|
936
|
+
asset_id: int,
|
|
937
|
+
coin: str,
|
|
938
|
+
side_is_buy: bool,
|
|
939
|
+
units: float,
|
|
940
|
+
slip_fraction: float,
|
|
941
|
+
cloid: str,
|
|
942
|
+
builder_fee: dict[str, Any] | None = None,
|
|
943
|
+
) -> LegSubmitResult:
|
|
944
|
+
"""Submit an IOC order for one leg."""
|
|
945
|
+
start_ms = _now_ms()
|
|
946
|
+
rounded_units = self.adapter.get_valid_order_size(asset_id, units)
|
|
947
|
+
if rounded_units <= 0:
|
|
948
|
+
raise ValueError(
|
|
949
|
+
f"Units {units} for {coin} rounded below venue minimum for asset {asset_id}."
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
success, response = await self.adapter.place_market_order(
|
|
953
|
+
asset_id,
|
|
954
|
+
side_is_buy,
|
|
955
|
+
slip_fraction,
|
|
956
|
+
rounded_units,
|
|
957
|
+
self.address,
|
|
958
|
+
cloid=cloid,
|
|
959
|
+
builder=builder_fee,
|
|
960
|
+
)
|
|
961
|
+
if not success:
|
|
962
|
+
response = {"status": "error", "error": response}
|
|
963
|
+
|
|
964
|
+
oids, immediate_units, immediate_notional = _parse_oids_and_immediate_fill(
|
|
965
|
+
response if isinstance(response, dict) else {}
|
|
966
|
+
)
|
|
967
|
+
coin_label = f"{coin}/USDC" if is_spot else coin
|
|
968
|
+
return LegSubmitResult(
|
|
969
|
+
oids=oids,
|
|
970
|
+
start_ms=start_ms,
|
|
971
|
+
coin_label=coin_label,
|
|
972
|
+
immediate_units=immediate_units,
|
|
973
|
+
immediate_notional=immediate_notional,
|
|
974
|
+
response=response if isinstance(response, dict) else {},
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
async def _spot_usdc_available(self) -> float:
|
|
978
|
+
"""Get available USDC balance for spot trades."""
|
|
979
|
+
try:
|
|
980
|
+
success, state = await self.adapter.get_spot_user_state(self.address)
|
|
981
|
+
if not success:
|
|
982
|
+
return 0.0
|
|
983
|
+
except Exception as exc:
|
|
984
|
+
logger.info(
|
|
985
|
+
f"Failed to fetch spot balances for {self.address} while sizing repairs: {exc}"
|
|
986
|
+
)
|
|
987
|
+
return 0.0
|
|
988
|
+
|
|
989
|
+
balances = state.get("balances", [])
|
|
990
|
+
for balance in balances:
|
|
991
|
+
if balance.get("coin") == "USDC":
|
|
992
|
+
try:
|
|
993
|
+
return float(balance.get("total", 0.0))
|
|
994
|
+
except (TypeError, ValueError):
|
|
995
|
+
return 0.0
|
|
996
|
+
return 0.0
|
|
997
|
+
|
|
998
|
+
async def _max_spot_units(
|
|
999
|
+
self,
|
|
1000
|
+
desired_units: float,
|
|
1001
|
+
mid_price: float,
|
|
1002
|
+
slip_fraction: float,
|
|
1003
|
+
step: Decimal,
|
|
1004
|
+
min_units: float,
|
|
1005
|
+
) -> float:
|
|
1006
|
+
"""Calculate maximum spot units based on available USDC."""
|
|
1007
|
+
if desired_units <= 0 or mid_price <= 0:
|
|
1008
|
+
return 0.0
|
|
1009
|
+
available = await self._spot_usdc_available()
|
|
1010
|
+
if available <= 0:
|
|
1011
|
+
return 0.0
|
|
1012
|
+
buffer = 1.0 + slip_fraction + 0.001
|
|
1013
|
+
max_units = available / (mid_price * buffer)
|
|
1014
|
+
if max_units <= 0:
|
|
1015
|
+
return 0.0
|
|
1016
|
+
max_units = _round_down_units(max_units, step)
|
|
1017
|
+
if max_units < min_units:
|
|
1018
|
+
return 0.0
|
|
1019
|
+
return min(desired_units, max_units)
|
|
1020
|
+
|
|
1021
|
+
def _min_units_for_notional(self, mid_price: float, step: Decimal) -> float:
|
|
1022
|
+
"""Calculate minimum units to meet notional threshold."""
|
|
1023
|
+
if mid_price <= 0:
|
|
1024
|
+
return max(float(step), 0.0)
|
|
1025
|
+
raw_units = MIN_NOTIONAL_USD / mid_price
|
|
1026
|
+
quantized = _round_up_units(raw_units, step)
|
|
1027
|
+
if quantized <= 0:
|
|
1028
|
+
return float(step)
|
|
1029
|
+
return max(float(step), quantized)
|
|
1030
|
+
|
|
1031
|
+
def _common_step(self, spot_asset_id: int, perp_asset_id: int) -> Decimal:
|
|
1032
|
+
"""Get common step size for both assets."""
|
|
1033
|
+
spot_decimals = self.adapter.get_sz_decimals(spot_asset_id)
|
|
1034
|
+
perp_decimals = self.adapter.get_sz_decimals(perp_asset_id)
|
|
1035
|
+
return Decimal(10) ** -min(spot_decimals, perp_decimals)
|
|
1036
|
+
|
|
1037
|
+
@staticmethod
|
|
1038
|
+
def _build_order_pointer(
|
|
1039
|
+
response: dict[str, Any],
|
|
1040
|
+
reason: str,
|
|
1041
|
+
metadata: dict[str, Any],
|
|
1042
|
+
) -> dict[str, Any] | None:
|
|
1043
|
+
"""Build order pointer for tracking."""
|
|
1044
|
+
if not response or response.get("status") != "ok":
|
|
1045
|
+
return None
|
|
1046
|
+
return {
|
|
1047
|
+
"reason": reason,
|
|
1048
|
+
"response": response,
|
|
1049
|
+
"metadata": metadata,
|
|
1050
|
+
}
|