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.

Files changed (50) hide show
  1. wayfinder_paths/adapters/balance_adapter/README.md +0 -10
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +0 -20
  3. wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -30
  4. wayfinder_paths/adapters/brap_adapter/adapter.py +3 -2
  5. wayfinder_paths/adapters/brap_adapter/test_adapter.py +9 -13
  6. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +14 -7
  7. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
  8. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
  9. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
  10. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
  11. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
  12. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
  13. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
  14. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
  15. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
  16. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +7 -6
  17. wayfinder_paths/adapters/pool_adapter/README.md +3 -28
  18. wayfinder_paths/adapters/pool_adapter/adapter.py +0 -72
  19. wayfinder_paths/adapters/pool_adapter/examples.json +0 -43
  20. wayfinder_paths/adapters/pool_adapter/test_adapter.py +4 -54
  21. wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -14
  22. wayfinder_paths/core/adapters/models.py +9 -4
  23. wayfinder_paths/core/analytics/__init__.py +11 -0
  24. wayfinder_paths/core/analytics/bootstrap.py +57 -0
  25. wayfinder_paths/core/analytics/stats.py +48 -0
  26. wayfinder_paths/core/analytics/test_analytics.py +170 -0
  27. wayfinder_paths/core/clients/BRAPClient.py +1 -0
  28. wayfinder_paths/core/clients/LedgerClient.py +2 -7
  29. wayfinder_paths/core/clients/PoolClient.py +0 -16
  30. wayfinder_paths/core/clients/WalletClient.py +0 -27
  31. wayfinder_paths/core/clients/protocols.py +104 -18
  32. wayfinder_paths/scripts/make_wallets.py +9 -0
  33. wayfinder_paths/scripts/run_strategy.py +124 -0
  34. wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
  35. wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
  36. wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
  37. wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
  38. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
  39. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
  40. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
  41. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
  42. wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
  43. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +1 -9
  44. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +36 -5
  45. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +367 -278
  46. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +204 -7
  47. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/METADATA +32 -3
  48. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/RECORD +50 -27
  49. {wayfinder_paths-0.1.6.dist-info → wayfinder_paths-0.1.8.dist-info}/LICENSE +0 -0
  50. {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
+ }