hypertrader 0.1.0__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.
@@ -0,0 +1,1911 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from typing import Optional, List, Dict, Any, Tuple
5
+
6
+ from hyperliquid.exchange import Exchange
7
+ from hyperliquid.info import Info
8
+
9
+ from utils.helpers import compute_default_stop_loss_pct, get_open_orders_for_coin
10
+ from utils.constants import WATCH_RETRY_SLEEP_SECONDS, PRICE_EPS
11
+ from utils.helpers import init_clients, get_position_for_coin, \
12
+ parse_position_snapshot, get_all_mids, compute_position_unrealized_pnl, get_account_runtime_metrics, \
13
+ position_is_directional_add, \
14
+ fmt_optional_float, format_account_metrics, is_rate_limit_error, \
15
+ round_size_for_hyperliquid, get_position_size_for_coin, AccountRuntimeMetrics, get_best_bid_ask, \
16
+ round_price_for_hyperliquid, extract_order_error, close_clients, fetch_recent_candles
17
+
18
+ try:
19
+ import numpy as np
20
+ import talib
21
+ except Exception:
22
+ np = None # type: ignore[assignment]
23
+ talib = None # type: ignore[assignment]
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Bracket entry / TP / SL
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def extract_resting_oids(resp: Any) -> List[int]:
31
+ """Return resting order ids from an exchange.order/bulk_orders response."""
32
+ oids: List[int] = []
33
+ try:
34
+ statuses = resp.get("response", {}).get("data", {}).get("statuses", [])
35
+ for status in statuses:
36
+ resting = status.get("resting") if isinstance(status, dict) else None
37
+ if isinstance(resting, dict) and isinstance(resting.get("oid"), int):
38
+ oids.append(int(resting["oid"]))
39
+ except Exception:
40
+ return oids
41
+ return oids
42
+
43
+ def extract_filled_qty(resp: Any) -> float:
44
+ """Return filled quantity from an exchange.order/bulk_orders response if present."""
45
+ total = 0.0
46
+ try:
47
+ statuses = resp.get("response", {}).get("data", {}).get("statuses", [])
48
+ for status in statuses:
49
+ filled = status.get("filled") if isinstance(status, dict) else None
50
+ if isinstance(filled, dict):
51
+ total += float(filled.get("totalSz", "0"))
52
+ except Exception:
53
+ return total
54
+ return total
55
+
56
+ async def get_frontend_open_orders_for_coin(
57
+ info: Info,
58
+ account_address: str,
59
+ coin: str,
60
+ ) -> List[Dict[str, Any]]:
61
+ """Return frontend open orders for a coin, including reduceOnly/isTrigger fields."""
62
+ try:
63
+ orders = await info.frontend_open_orders(account_address)
64
+ except Exception:
65
+ logging.getLogger("hypertrader").exception(
66
+ "[WARN] Failed to fetch frontend open orders for %s.",
67
+ coin,
68
+ )
69
+ return []
70
+
71
+ out: List[Dict[str, Any]] = []
72
+ for order in orders:
73
+ try:
74
+ if str(order.get("coin", "")).lower() == coin.lower():
75
+ out.append(order)
76
+ except Exception:
77
+ continue
78
+ return out
79
+
80
+
81
+ def clamp_tp_reversal_trigger_px(side: str, entry_px: float, candidate_px: float) -> float:
82
+ """Clamp TP-reversal triggers so they never cross the average entry.
83
+
84
+ For a long, the TP-reversal trigger is never allowed below entry.
85
+ For a short, the TP-reversal trigger is never allowed above entry.
86
+ """
87
+ if side == "long":
88
+ return max(float(entry_px), float(candidate_px))
89
+ if side == "short":
90
+ return min(float(entry_px), float(candidate_px))
91
+ raise RuntimeError(f"Invalid side: {side}")
92
+
93
+ def compute_tp_reversal_trigger_px(side: str, entry_px: float, favorable_extreme: float, reversal_pct: float) -> float:
94
+ """Return the TP-reversal trigger using favorable-extreme logic.
95
+
96
+ The reversal threshold is a percentage move back from the best favorable
97
+ price reached after the first TP level was touched. The result is always
98
+ entry-clamped: long triggers cannot be below entry, and short triggers
99
+ cannot be above entry. This prevents TP reversal from turning into a loss
100
+ exit level merely because the configured reversal percentage is larger than
101
+ the current unrealized gain.
102
+ """
103
+ if side not in ("long", "short"):
104
+ raise RuntimeError(f"Invalid side: {side}")
105
+ if not (0.0 < reversal_pct < 1.0):
106
+ raise RuntimeError("reversal_pct must be a decimal fraction between 0 and 1.")
107
+
108
+ if side == "long":
109
+ if favorable_extreme <= entry_px:
110
+ return float(entry_px)
111
+ candidate_px = favorable_extreme * (1.0 - reversal_pct)
112
+ else:
113
+ if favorable_extreme >= entry_px:
114
+ return float(entry_px)
115
+ candidate_px = favorable_extreme * (1.0 + reversal_pct)
116
+
117
+ return clamp_tp_reversal_trigger_px(side, entry_px, candidate_px)
118
+
119
+
120
+ def resolve_tp_reversal_stop_buffer_pct(
121
+ reversal_pct: float,
122
+ configured_stop_buffer_pct: Optional[float],
123
+ ) -> float:
124
+ """Resolve the TP-reversal stop buffer; default to 20% of the reversal threshold."""
125
+ stop_buffer_pct = configured_stop_buffer_pct
126
+ if stop_buffer_pct is None:
127
+ stop_buffer_pct = reversal_pct * 0.2
128
+ if not (0.0 < stop_buffer_pct < 1.0):
129
+ raise RuntimeError("tp_reversal_stop_buffer_pct must be a decimal fraction between 0 and 1.")
130
+ return stop_buffer_pct
131
+
132
+ def compute_stop_loss_trigger_px(side: str, entry_px: float, stop_loss_pct: float) -> float:
133
+ """Compute the private stop-loss trigger from entry and pct."""
134
+ if not (0.0 < stop_loss_pct < 1.0):
135
+ raise RuntimeError("stop_loss_pct must be a decimal fraction between 0 and 1.")
136
+ if side == "long":
137
+ return entry_px * (1.0 - stop_loss_pct)
138
+ if side == "short":
139
+ return entry_px * (1.0 + stop_loss_pct)
140
+ raise RuntimeError(f"Invalid side: {side}")
141
+
142
+ def stop_loss_trigger_px_is_valid(side: str, entry_px: float, trigger_px: Optional[float]) -> bool:
143
+ """Return True when an absolute stop trigger is side-correct relative to entry."""
144
+ if trigger_px is None or trigger_px <= 0.0:
145
+ return False
146
+ if side == "long":
147
+ return trigger_px < entry_px
148
+ if side == "short":
149
+ return trigger_px > entry_px
150
+ raise RuntimeError(f"Invalid side: {side}")
151
+
152
+
153
+ def resolve_stop_loss_trigger_px(
154
+ side: str,
155
+ entry_px: float,
156
+ stop_loss_pct: Optional[float],
157
+ stop_loss_trigger_px: Optional[float],
158
+ ) -> Tuple[Optional[float], str]:
159
+ """Resolve the active stop trigger, preferring an absolute trigger when valid."""
160
+ if stop_loss_trigger_px is not None:
161
+ if stop_loss_trigger_px_is_valid(side, entry_px, stop_loss_trigger_px):
162
+ return float(stop_loss_trigger_px), "absolute"
163
+ print(
164
+ f"[SL-WARN] Ignoring invalid absolute stop trigger for {side}: "
165
+ f"entry={entry_px:.8f} trigger={float(stop_loss_trigger_px):.8f}"
166
+ )
167
+ if stop_loss_pct is not None:
168
+ return compute_stop_loss_trigger_px(side, entry_px, stop_loss_pct), "pct"
169
+ return None, "none"
170
+
171
+
172
+ def format_tp_targets(tp_orders: List[Dict[str, Any]], placed_levels: Optional[set[int]] = None) -> str:
173
+ if not tp_orders:
174
+ return "N/A"
175
+ placed_levels = placed_levels or set()
176
+ chunks = []
177
+ for order in tp_orders:
178
+ level = int(order.get("level", 0))
179
+ status = "placed" if level in placed_levels else "hidden"
180
+ chunks.append(f"L{level}:{float(order['px']):.8f}/{float(order['sz']):.8f}/{status}")
181
+ return ",".join(chunks)
182
+
183
+
184
+ def build_tp_level_to_oid_map(tp_orders: List[Dict[str, Any]], tp_oids: List[int]) -> Dict[int, int]:
185
+ """Best-effort mapping from TP ladder level to exchange order id."""
186
+ mapping: Dict[int, int] = {}
187
+ for order, oid in zip(tp_orders, tp_oids):
188
+ if isinstance(oid, int):
189
+ mapping[int(order.get("level", 0))] = oid
190
+ return mapping
191
+
192
+ def price_hit_take_profit(side: str, price: float, target_px: float) -> bool:
193
+ if side == "long":
194
+ return price >= target_px
195
+ if side == "short":
196
+ return price <= target_px
197
+ raise RuntimeError(f"Invalid side: {side}")
198
+
199
+ def price_hit_stop(side: str, price: float, stop_px: Optional[float]) -> bool:
200
+ if stop_px is None:
201
+ return False
202
+ if side == "long":
203
+ return price <= stop_px
204
+ if side == "short":
205
+ return price >= stop_px
206
+ raise RuntimeError(f"Invalid side: {side}")
207
+
208
+
209
+ def compute_trailing_take_profit_stop_px(
210
+ side: str,
211
+ entry_px: float,
212
+ favorable_extreme: Optional[float],
213
+ trail_profit_pct: float,
214
+ current_stop_px: Optional[float] = None,
215
+ ) -> Optional[float]:
216
+ """Ratchet a local TP stop by a fraction of favorable unrealized profit."""
217
+ if favorable_extreme is None:
218
+ return current_stop_px
219
+ if not (0.0 < trail_profit_pct < 1.0):
220
+ raise RuntimeError("trail_profit_pct must be a decimal fraction between 0 and 1.")
221
+
222
+ retain_profit_fraction = 1.0 - trail_profit_pct
223
+ if side == "long":
224
+ favorable_profit = favorable_extreme - entry_px
225
+ if favorable_profit <= 0.0:
226
+ return current_stop_px
227
+ candidate = entry_px + (favorable_profit * retain_profit_fraction)
228
+ return max(entry_px, current_stop_px if current_stop_px is not None else entry_px, candidate)
229
+
230
+ if side == "short":
231
+ favorable_profit = entry_px - favorable_extreme
232
+ if favorable_profit <= 0.0:
233
+ return current_stop_px
234
+ candidate = entry_px - (favorable_profit * retain_profit_fraction)
235
+ return min(entry_px, current_stop_px if current_stop_px is not None else entry_px, candidate)
236
+
237
+ raise RuntimeError(f"Invalid side: {side}")
238
+
239
+ def signed_position_delta(initial_pos: float, current_pos: float, is_buy: bool) -> float:
240
+ """Return how much of the desired directional entry has been filled."""
241
+ direction = 1.0 if is_buy else -1.0
242
+ return max(0.0, (current_pos - initial_pos) * direction)
243
+
244
+ def order_side_matches(order: Dict[str, Any], is_buy: bool) -> bool:
245
+ """Best-effort side check across Hyperliquid open-order response variants."""
246
+ for key in ("isBuy", "is_buy"):
247
+ value = order.get(key)
248
+ if isinstance(value, bool):
249
+ return value == is_buy
250
+
251
+ side_value = order.get("side")
252
+ if side_value is None:
253
+ # Some response shapes omit side. For entry cleanup we treat that as a
254
+ # possible match so a stale entry order is not left behind before/after
255
+ # market fallback.
256
+ return True
257
+
258
+ side_str = str(side_value).strip().lower()
259
+ buy_values = {"b", "bid", "buy"}
260
+ sell_values = {"a", "ask", "s", "sell"}
261
+ if side_str in buy_values:
262
+ return is_buy
263
+ if side_str in sell_values:
264
+ return not is_buy
265
+
266
+ # Unknown side encoding: treat as possible match for cleanup.
267
+ return True
268
+
269
+ async def stop_limit_px_for_trigger(
270
+ info: Info,
271
+ coin: str,
272
+ is_exit_buy: bool,
273
+ trigger_px: float,
274
+ slippage: float,
275
+ ) -> float:
276
+ """Compute aggressive limit px for a market-style TP/SL trigger order."""
277
+ if slippage < 0.0:
278
+ raise RuntimeError("slippage must be >= 0")
279
+ raw_px = trigger_px * (1.0 + slippage) if is_exit_buy else trigger_px * (1.0 - slippage)
280
+ return await round_price_for_hyperliquid(info, coin, raw_px)
281
+
282
+ async def get_entry_limit_price(info: Info, coin: str, is_buy: bool) -> float:
283
+ """Return a top-of-book maker limit price for entry."""
284
+ best_bid, best_ask = await get_best_bid_ask(info, coin)
285
+ if is_buy and best_bid is not None:
286
+ return await round_price_for_hyperliquid(info, coin, best_bid)
287
+ if (not is_buy) and best_ask is not None:
288
+ return await round_price_for_hyperliquid(info, coin, best_ask)
289
+
290
+ mids = await get_all_mids(info)
291
+ if coin not in mids:
292
+ raise RuntimeError(f"No BBO or mid available for {coin}; cannot price entry order.")
293
+ return await round_price_for_hyperliquid(info, coin, float(mids[coin]))
294
+
295
+ async def cancel_entry_orders_for_coin(
296
+ info: Info,
297
+ exchange: Exchange,
298
+ account_address: str,
299
+ coin: str,
300
+ is_buy: Optional[bool] = None,
301
+ label: str = "entry orders",
302
+ ) -> None:
303
+ """Cancel remaining non-reduce-only entry orders for a coin.
304
+
305
+ This is used before and after market fallback. The tracked active oid can
306
+ become stale when an order is modified, partially filled, or returned in a
307
+ different response shape, so this reconciles against the authoritative open
308
+ order snapshot and removes any leftover non-reduce-only orders for the same
309
+ coin/side. Reduce-only TP orders are intentionally left alone.
310
+ """
311
+ orders = await get_frontend_open_orders_for_coin(info, account_address, coin)
312
+ oids: List[int] = []
313
+ for order in orders:
314
+ try:
315
+ if bool(order.get("reduceOnly", False)):
316
+ continue
317
+ if is_buy is not None and not order_side_matches(order, is_buy):
318
+ continue
319
+ oid = order.get("oid")
320
+ if isinstance(oid, int):
321
+ oids.append(oid)
322
+ except Exception:
323
+ continue
324
+
325
+ # frontendOpenOrders is preferred for reduceOnly filtering, but fall back to
326
+ # openOrders if it returned nothing. openOrders usually represents active
327
+ # limit orders, so anything found here before market fallback is treated as
328
+ # an entry-order candidate.
329
+ if not oids:
330
+ fallback_orders = await get_open_orders_for_coin(info, account_address, coin)
331
+ for order in fallback_orders:
332
+ try:
333
+ if is_buy is not None and not order_side_matches(order, is_buy):
334
+ continue
335
+ oid = order.get("oid")
336
+ if isinstance(oid, int):
337
+ oids.append(oid)
338
+ except Exception:
339
+ continue
340
+
341
+ # Preserve order but deduplicate.
342
+ deduped_oids = list(dict.fromkeys(oids))
343
+ await cancel_oids(exchange, coin, deduped_oids, label)
344
+
345
+
346
+ async def cancel_oids(exchange: Exchange, coin: str, oids: List[int], label: str = "orders") -> None:
347
+ """Bulk-cancel a list of order ids for one coin."""
348
+ clean_oids = [int(oid) for oid in oids if isinstance(oid, int)]
349
+ if not clean_oids:
350
+ return
351
+
352
+ reqs = [{"coin": coin, "oid": oid} for oid in clean_oids]
353
+ try:
354
+ print(f"[CANCEL] Canceling {len(reqs)} {label} for {coin}: {clean_oids}")
355
+ resp = await exchange.bulk_cancel(reqs)
356
+ print(f"[CANCEL] {coin} {label} cancel response: {resp}")
357
+ except Exception as exc:
358
+ print(f"[WARN] Failed to cancel {label} for {coin}: {exc}")
359
+
360
+
361
+ def update_hidden_stop_from_favorable_extreme(
362
+ side: str,
363
+ entry_px: float,
364
+ current_stop_px: Optional[float],
365
+ favorable_extreme: Optional[float],
366
+ stop_loss_pct: Optional[float],
367
+ ) -> Optional[float]:
368
+ """Ratchet a private stop when the trade is in profit; never loosen it."""
369
+ if stop_loss_pct is None:
370
+ return current_stop_px
371
+
372
+ base_stop = compute_stop_loss_trigger_px(side, entry_px, stop_loss_pct)
373
+ if favorable_extreme is None:
374
+ return current_stop_px if current_stop_px is not None else base_stop
375
+
376
+ if side == "long":
377
+ candidate = favorable_extreme * (1.0 - stop_loss_pct)
378
+ if favorable_extreme <= entry_px:
379
+ candidate = base_stop
380
+ return max(base_stop, current_stop_px if current_stop_px is not None else base_stop, candidate)
381
+
382
+ if side == "short":
383
+ candidate = favorable_extreme * (1.0 + stop_loss_pct)
384
+ if favorable_extreme >= entry_px:
385
+ candidate = base_stop
386
+ return min(base_stop, current_stop_px if current_stop_px is not None else base_stop, candidate)
387
+
388
+ raise RuntimeError(f"Invalid side: {side}")
389
+
390
+ async def cancel_reduce_only_orders_for_coin(
391
+ info: Info,
392
+ exchange: Exchange,
393
+ account_address: str,
394
+ coin: str,
395
+ only_tpsl: bool = False,
396
+ ) -> None:
397
+ """Cancel reduce-only orders for a coin, optionally limiting to TP/SL trigger orders."""
398
+ orders = await get_frontend_open_orders_for_coin(info, account_address, coin)
399
+ oids: List[int] = []
400
+ for order in orders:
401
+ try:
402
+ if not bool(order.get("reduceOnly", False)):
403
+ continue
404
+ if only_tpsl and not bool(order.get("isTrigger", False)):
405
+ continue
406
+ oid = order.get("oid")
407
+ if isinstance(oid, int):
408
+ oids.append(oid)
409
+ except Exception:
410
+ continue
411
+ label = "reduce-only TP/SL orders" if only_tpsl else "reduce-only orders"
412
+ await cancel_oids(exchange, coin, oids, label)
413
+
414
+
415
+ async def get_open_reduce_only_take_profit_orders_for_coin(
416
+ info: Info,
417
+ account_address: str,
418
+ coin: str,
419
+ ) -> List[Dict[str, Any]]:
420
+ """Return open reduce-only non-trigger TP limit orders for a coin."""
421
+ orders = await get_frontend_open_orders_for_coin(info, account_address, coin)
422
+ tp_orders: List[Dict[str, Any]] = []
423
+ for order in orders:
424
+ try:
425
+ if not bool(order.get("reduceOnly", False)):
426
+ continue
427
+ if bool(order.get("isTrigger", False)):
428
+ continue
429
+ tp_orders.append(order)
430
+ except Exception:
431
+ continue
432
+ return tp_orders
433
+
434
+
435
+ async def place_reduce_only_stop_market_order(
436
+ info: Info,
437
+ exchange: Exchange,
438
+ coin: str,
439
+ side: str,
440
+ position_size_abs: float,
441
+ trigger_px: float,
442
+ slippage: float,
443
+ label: str,
444
+ ) -> Tuple[Optional[int], float]:
445
+ """Place a reduce-only stop-market order for the current position size at a given trigger."""
446
+ size = await round_size_for_hyperliquid(info, coin, position_size_abs)
447
+ is_exit_buy = side == "short"
448
+ trigger_px = await round_price_for_hyperliquid(info, coin, trigger_px)
449
+ limit_px = await stop_limit_px_for_trigger(info, coin, is_exit_buy, trigger_px, slippage)
450
+ order_type = {"trigger": {"triggerPx": trigger_px, "isMarket": True, "tpsl": "sl"}}
451
+ side_label = "BUY" if is_exit_buy else "SELL"
452
+
453
+ print(
454
+ f"[{label}] Placing reduce-only stop-market {side_label} {size:.8f} {coin}: "
455
+ f"trigger={trigger_px:.8f}, limit_px={limit_px:.8f}"
456
+ )
457
+ resp = await exchange.order(coin, is_exit_buy, size, limit_px, order_type, reduce_only=True)
458
+ print(f"[{label}] response: {resp}")
459
+ oids = extract_resting_oids(resp)
460
+ return (oids[0] if oids else None), trigger_px
461
+
462
+
463
+ async def exit_on_tp_reversal(
464
+ info: Info,
465
+ exchange: Exchange,
466
+ account_address: str,
467
+ coin: str,
468
+ side: str,
469
+ live_signed_size: float,
470
+ reference_px: float,
471
+ reversal_pct: float,
472
+ market_slippage: float,
473
+ poll_interval: float,
474
+ limit_exit_first: bool,
475
+ stop_buffer_pct: Optional[float],
476
+ ) -> bool:
477
+ """Exit after a TP reversal, optionally trying limit+protective-stop first."""
478
+ if not limit_exit_first:
479
+ try:
480
+ resp = await exchange.market_close(coin, slippage=market_slippage)
481
+ print(f"[MARKET EXIT] response: {resp}")
482
+ return True
483
+ except Exception as exc:
484
+ print(f"[ERROR] Failed market close after TP reversal: {exc}")
485
+ return False
486
+
487
+ size_abs = abs(live_signed_size)
488
+ if size_abs <= 0.0:
489
+ print(f"[TP-REVERSAL EXIT] {coin} is already flat before exit placement.")
490
+ return True
491
+
492
+ is_exit_buy = side == "short"
493
+ side_label = "BUY" if is_exit_buy else "SELL"
494
+ best_bid, best_ask = await get_best_bid_ask(info, coin)
495
+
496
+ # Use a marketable limit: buy exits lift the ask, sell exits hit the bid.
497
+ # This is still a limit order, but it should fill immediately when book data is fresh.
498
+ if is_exit_buy and best_ask is not None:
499
+ raw_limit_px = best_ask
500
+ elif (not is_exit_buy) and best_bid is not None:
501
+ raw_limit_px = best_bid
502
+ else:
503
+ raw_limit_px = reference_px
504
+
505
+ limit_px = await round_price_for_hyperliquid(info, coin, raw_limit_px)
506
+ rounded_size = await round_size_for_hyperliquid(info, coin, size_abs)
507
+ stop_buffer_pct = resolve_tp_reversal_stop_buffer_pct(reversal_pct, stop_buffer_pct)
508
+ stop_trigger_raw = (
509
+ reference_px * (1.0 + stop_buffer_pct)
510
+ if is_exit_buy
511
+ else reference_px * (1.0 - stop_buffer_pct)
512
+ )
513
+
514
+ print(
515
+ f"[TP-REVERSAL EXIT] Attempting reduce-only limit {side_label} "
516
+ f"{rounded_size:.8f} {coin} @ {limit_px:.8f}; protective stop buffer "
517
+ f"{stop_buffer_pct * 100:.4f}% from ref={reference_px:.8f}"
518
+ )
519
+ try:
520
+ limit_resp = await exchange.order(
521
+ coin,
522
+ is_exit_buy,
523
+ rounded_size,
524
+ limit_px,
525
+ {"limit": {"tif": "Gtc"}},
526
+ reduce_only=True,
527
+ )
528
+ except Exception as exc:
529
+ print(f"[ERROR] Failed TP-reversal limit exit placement for {coin}: {exc}")
530
+ return False
531
+ print(f"[TP-REVERSAL LIMIT] response: {limit_resp}")
532
+ limit_oids = extract_resting_oids(limit_resp)
533
+
534
+ await asyncio.sleep(min(max(poll_interval * 0.25, 0.1), 0.5))
535
+ live_pos = await get_position_for_coin(info, account_address, coin)
536
+ if live_pos is None:
537
+ print(f"[TP-REVERSAL EXIT] {coin} fully closed on the limit order.")
538
+ return True
539
+
540
+ try:
541
+ _, remaining_signed_size, _, remaining_side, remaining_abs = parse_position_snapshot(live_pos)
542
+ except RuntimeError as exc:
543
+ print(f"[WARN] {exc}")
544
+ remaining_signed_size = live_signed_size
545
+ remaining_side = side
546
+ remaining_abs = size_abs
547
+
548
+ stop_oid: Optional[int] = None
549
+ stop_trigger_px: Optional[float] = None
550
+ if remaining_abs > 0.0:
551
+ try:
552
+ stop_oid, stop_trigger_px = await place_reduce_only_stop_market_order(
553
+ info=info,
554
+ exchange=exchange,
555
+ coin=coin,
556
+ side=remaining_side,
557
+ position_size_abs=remaining_abs,
558
+ trigger_px=stop_trigger_raw,
559
+ slippage=market_slippage,
560
+ label="TP-REVERSAL STOP",
561
+ )
562
+ except Exception as exc:
563
+ print(f"[ERROR] Failed TP-reversal stop placement for {coin}: {exc}")
564
+ await cancel_oids(exchange, coin, limit_oids, "tp reversal limit orders")
565
+ return False
566
+
567
+ wait_seconds = max(1.0, poll_interval * 3.0)
568
+ deadline = time.time() + wait_seconds
569
+ while time.time() < deadline:
570
+ await asyncio.sleep(min(max(poll_interval, 0.2), 1.0))
571
+ live_pos = await get_position_for_coin(info, account_address, coin)
572
+ if live_pos is None:
573
+ print(f"[TP-REVERSAL EXIT] {coin} flat after paired limit/stop exit.")
574
+ return True
575
+ try:
576
+ _, remaining_signed_size, _, _, remaining_abs = parse_position_snapshot(live_pos)
577
+ except RuntimeError:
578
+ print(f"[TP-REVERSAL EXIT] {coin} is no longer manageable; treating as closed.")
579
+ return True
580
+ print(
581
+ f"[TP-REVERSAL EXIT] Waiting for {coin} exit fill; remaining signed size={remaining_signed_size:.8f} "
582
+ f"limit_oids={limit_oids} stop_oid={stop_oid} stop_trigger="
583
+ f"{f'{stop_trigger_px:.8f}' if stop_trigger_px is not None else 'N/A'}"
584
+ )
585
+
586
+ await cancel_oids(exchange, coin, limit_oids, "tp reversal limit orders")
587
+ if stop_oid is not None:
588
+ await cancel_oids(exchange, coin, [stop_oid], "tp reversal stop orders")
589
+
590
+ live_pos = await get_position_for_coin(info, account_address, coin)
591
+ if live_pos is None:
592
+ print(f"[TP-REVERSAL EXIT] {coin} flat after canceling remaining exit orders.")
593
+ return True
594
+
595
+ print(f"[TP-REVERSAL EXIT] Limit/stop exit did not complete in time for {coin}; falling back to market close.")
596
+ try:
597
+ resp = await exchange.market_close(coin, slippage=market_slippage)
598
+ print(f"[MARKET EXIT] response: {resp}")
599
+ return True
600
+ except Exception as exc:
601
+ print(f"[ERROR] Failed market close after TP reversal limit fallback: {exc}")
602
+ return False
603
+
604
+
605
+ async def place_hidden_take_profit_order(
606
+ info: Info,
607
+ exchange: Exchange,
608
+ coin: str,
609
+ side: str,
610
+ order: Dict[str, Any],
611
+ tp_tif: str,
612
+ ) -> List[int]:
613
+ """Place one hidden TP level as a post-only reduce-only limit once touched."""
614
+ if tp_tif not in ("Alo", "Gtc"):
615
+ raise RuntimeError("tp_tif must be Alo or Gtc.")
616
+
617
+ is_exit_buy = bool(order["is_buy"])
618
+ target_px = float(order["px"])
619
+ size = await round_size_for_hyperliquid(info, coin, float(order["sz"]))
620
+ best_bid, best_ask = await get_best_bid_ask(info, coin)
621
+
622
+ # Keep it maker/post-only. For long exits this is a sell resting just above ask;
623
+ # for short exits this is a buy resting just below bid. The target remains the
624
+ # trigger condition; placement price may be nudged to avoid taker execution.
625
+ if side == "long":
626
+ px = target_px
627
+ if best_ask is not None:
628
+ px = max(px, best_ask + PRICE_EPS)
629
+ elif side == "short":
630
+ px = target_px
631
+ if best_bid is not None:
632
+ px = min(px, best_bid - PRICE_EPS)
633
+ else:
634
+ raise RuntimeError(f"Invalid side: {side}")
635
+
636
+ px = await round_price_for_hyperliquid(info, coin, px)
637
+ side_label = "BUY" if is_exit_buy else "SELL"
638
+ print(
639
+ f"[HIDDEN-TP] Target touched; placing reduce-only post-only {side_label} "
640
+ f"{size:.8f} {coin} @ {px:.8f} for L{int(order.get('level', 0)):02d} "
641
+ f"target={target_px:.8f}"
642
+ )
643
+ resp = await exchange.order(
644
+ coin,
645
+ is_exit_buy,
646
+ size,
647
+ px,
648
+ {"limit": {"tif": "Alo" if tp_tif == "Alo" else tp_tif}},
649
+ reduce_only=True,
650
+ )
651
+ print(f"[HIDDEN-TP] response: {resp}")
652
+ err = extract_order_error(resp)
653
+ if err is not None:
654
+ print(f"[HIDDEN-TP-WARN] TP level was not placed: {err}")
655
+ return []
656
+ return extract_resting_oids(resp)
657
+
658
+
659
+ async def enter_position_with_reposting_limit(
660
+ info: Info,
661
+ exchange: Exchange,
662
+ account_address: str,
663
+ coin: str,
664
+ is_buy: bool,
665
+ size: float,
666
+ retries: int,
667
+ repost_interval: float,
668
+ entry_tif: str,
669
+ market_fallback: bool,
670
+ market_slippage: float,
671
+ metrics_start_time_ms: Optional[int] = None,
672
+ ) -> Dict[str, Any]:
673
+ """Enter with one resting top-of-book limit order, modifying it instead of cancel/repost.
674
+
675
+ The previous implementation canceled the resting entry order every loop and
676
+ submitted a fresh one. This version places one order, keeps its oid, and on
677
+ subsequent iterations sends a single modify_order request with the updated
678
+ top-of-book price and remaining size. A cancel is only sent when the entry is
679
+ done, the order becomes unusable, or the bot is about to fall back to market.
680
+ """
681
+ if size <= 0.0:
682
+ raise RuntimeError("Entry size must be > 0.")
683
+ if retries < 0:
684
+ raise RuntimeError("Entry retries must be >= 0.")
685
+ if repost_interval <= 0.0:
686
+ raise RuntimeError("Entry repost interval must be > 0.")
687
+ if entry_tif not in ("Alo", "Gtc"):
688
+ raise RuntimeError("entry_tif must be Alo or Gtc.")
689
+
690
+ size = await round_size_for_hyperliquid(info, coin, size)
691
+ initial_pos = await get_position_size_for_coin(info, account_address, coin)
692
+ direction_label = "LONG" if is_buy else "SHORT"
693
+
694
+ if initial_pos != 0.0:
695
+ print(
696
+ f"[WARN] Existing {coin} position is {initial_pos:.8f}. "
697
+ "Entry fill will be measured as directional delta from that starting position."
698
+ )
699
+
700
+ print("============================================================")
701
+ print(" Hyperliquid Async Smart Entry")
702
+ print("============================================================")
703
+ print(f"Coin: {coin}")
704
+ print(f"Direction: {direction_label}")
705
+ print(f"Requested size: {size:.8f}")
706
+ print(f"Entry TIF: {entry_tif}")
707
+ print(f"Limit attempts: {retries}")
708
+ print(f"Modify interval: {repost_interval:.2f}s")
709
+ print(f"Market fallback: {market_fallback}")
710
+ print("============================================================")
711
+
712
+ active_oid: Optional[int] = None
713
+ side = "BUY" if is_buy else "SELL"
714
+
715
+ async def get_entry_progress() -> Tuple[float, float, float, str, AccountRuntimeMetrics]:
716
+ """Return current_pos, filled_delta, remaining_size, formatted uPnL, account metrics."""
717
+ _current_pos = await get_position_size_for_coin(info, account_address, coin)
718
+ filled_delta = signed_position_delta(initial_pos, _current_pos, is_buy)
719
+ remaining_size = max(0.0, size - filled_delta)
720
+
721
+ pos_snapshot = await get_position_for_coin(info, account_address, coin)
722
+ mids = await get_all_mids(info)
723
+ mid_price_raw = mids.get(coin)
724
+ _upnl_str = "N/A"
725
+ if pos_snapshot is not None and mid_price_raw is not None:
726
+ try:
727
+ unrealized_pnl = compute_position_unrealized_pnl(pos_snapshot, float(mid_price_raw))
728
+ except (TypeError, ValueError):
729
+ unrealized_pnl = None
730
+ if unrealized_pnl is not None:
731
+ _upnl_str = f"{unrealized_pnl:.8f}"
732
+ _metrics = await get_account_runtime_metrics(info, account_address, metrics_start_time_ms, coin=coin)
733
+ return _current_pos, filled_delta, remaining_size, _upnl_str, _metrics
734
+
735
+ async def cancel_active_entry_order(label: str, reconcile: bool = False) -> None:
736
+ nonlocal active_oid
737
+ if active_oid is not None:
738
+ await cancel_oids(exchange, coin, [active_oid], label)
739
+ active_oid = None
740
+ if reconcile:
741
+ await cancel_entry_orders_for_coin(info, exchange, account_address, coin, is_buy=is_buy, label=label)
742
+
743
+ for attempt in range(1, retries + 1):
744
+ current_pos, filled, remaining, upnl_str, metrics = await get_entry_progress()
745
+ print(
746
+ f"[ENTRY {attempt}/{retries}] status filled_delta={filled:.8f} "
747
+ f"remaining={remaining:.8f} position_now={current_pos:.8f} upnl={upnl_str} "
748
+ f"{format_account_metrics(metrics)} active_oid={active_oid if active_oid is not None else 'N/A'}"
749
+ )
750
+ if remaining <= 0.0:
751
+ print(f"[ENTRY] Target position filled before attempt {attempt}.")
752
+ await cancel_active_entry_order("excess entry order after target fill", reconcile=True)
753
+ break
754
+
755
+ remaining = await round_size_for_hyperliquid(info, coin, remaining)
756
+ limit_px = await get_entry_limit_price(info, coin, is_buy)
757
+ order_type = {"limit": {"tif": entry_tif}}
758
+
759
+ if active_oid is None:
760
+ print(
761
+ f"[ENTRY {attempt}/{retries}] placing {side} {remaining:.8f} {coin} @ {limit_px:.8f} "
762
+ f"({entry_tif}, top-of-book)"
763
+ )
764
+ try:
765
+ resp = await exchange.order(
766
+ coin,
767
+ is_buy,
768
+ remaining,
769
+ limit_px,
770
+ order_type,
771
+ reduce_only=False,
772
+ )
773
+ print(f"[ENTRY {attempt}] place response: {resp}")
774
+ except Exception as exc:
775
+ print(f"[WARN] Entry order placement attempt {attempt} failed: {exc}")
776
+ await asyncio.sleep(repost_interval)
777
+ continue
778
+
779
+ err = extract_order_error(resp)
780
+ if err is not None:
781
+ print(f"[WARN] Entry placement rejected: {err}")
782
+ await asyncio.sleep(repost_interval)
783
+ continue
784
+
785
+ resting_oids = extract_resting_oids(resp)
786
+ if resting_oids:
787
+ active_oid = resting_oids[0]
788
+ print(f"[ENTRY {attempt}] active entry oid={active_oid}")
789
+ else:
790
+ filled_now = extract_filled_qty(resp)
791
+ if filled_now > 0.0:
792
+ print(f"[ENTRY {attempt}] order filled immediately for {filled_now:.8f}; no resting oid.")
793
+ else:
794
+ print("[ENTRY] No resting oid returned; will re-check position before another placement.")
795
+ else:
796
+ print(
797
+ f"[ENTRY {attempt}/{retries}] modifying oid={active_oid} -> "
798
+ f"{side} {remaining:.8f} {coin} @ {limit_px:.8f} ({entry_tif}, top-of-book)"
799
+ )
800
+ try:
801
+ resp = await exchange.modify_order(
802
+ active_oid,
803
+ coin,
804
+ is_buy,
805
+ remaining,
806
+ limit_px,
807
+ order_type,
808
+ reduce_only=False,
809
+ )
810
+ print(f"[ENTRY {attempt}] modify response: {resp}")
811
+ except Exception as exc:
812
+ print(f"[WARN] Entry order modify failed for oid={active_oid}: {exc}")
813
+ active_oid = None
814
+ await asyncio.sleep(repost_interval)
815
+ continue
816
+
817
+ err = extract_order_error(resp)
818
+ if err is not None:
819
+ print(f"[WARN] Entry modify rejected for oid={active_oid}: {err}")
820
+ lowered = err.lower()
821
+ if any(token in lowered for token in
822
+ ("does not exist", "not found", "already filled", "not open", "canceled")):
823
+ active_oid = None
824
+ await asyncio.sleep(repost_interval)
825
+ continue
826
+
827
+ await asyncio.sleep(repost_interval)
828
+
829
+ current_pos, filled, remaining, upnl_str, metrics = await get_entry_progress()
830
+ print(
831
+ f"[ENTRY FINAL] filled_delta={filled:.8f}, remaining={remaining:.8f}, "
832
+ f"position_now={current_pos:.8f}, upnl={upnl_str}, {format_account_metrics(metrics)}, "
833
+ f"active_oid={active_oid if active_oid is not None else 'N/A'}"
834
+ )
835
+
836
+ if remaining <= 0.0:
837
+ await cancel_active_entry_order("excess entry order after complete fill", reconcile=True)
838
+ elif remaining > 0.0:
839
+ if market_fallback:
840
+ await cancel_active_entry_order("entry order before market fallback", reconcile=True)
841
+ await asyncio.sleep(0.15)
842
+ await cancel_entry_orders_for_coin(
843
+ info,
844
+ exchange,
845
+ account_address,
846
+ coin,
847
+ is_buy=is_buy,
848
+ label="entry order reconcile before market fallback",
849
+ )
850
+ current_pos, filled, remaining, upnl_str, metrics = await get_entry_progress()
851
+ print(
852
+ f"[ENTRY] After cancel reconcile: filled_delta={filled:.8f}, "
853
+ f"remaining={remaining:.8f}, position_now={current_pos:.8f}, upnl={upnl_str}, "
854
+ f"{format_account_metrics(metrics)}"
855
+ )
856
+ if remaining <= 0.0:
857
+ print("[ENTRY] Target filled while canceling/reconciling the limit order; skipping market fallback.")
858
+ await cancel_entry_orders_for_coin(
859
+ info,
860
+ exchange,
861
+ account_address,
862
+ coin,
863
+ is_buy=is_buy,
864
+ label="entry order final cleanup after cancel reconcile",
865
+ )
866
+ else:
867
+ remaining = await round_size_for_hyperliquid(info, coin, remaining)
868
+ print(
869
+ f"[ENTRY] Limit modify attempts exhausted with {remaining:.8f} {coin} remaining. "
870
+ f"Falling back to market_open with slippage={market_slippage:.4f}."
871
+ )
872
+ try:
873
+ resp = await exchange.market_open(coin, is_buy, remaining, slippage=market_slippage)
874
+ print(f"[MARKET ENTRY] response: {resp}")
875
+ await asyncio.sleep(0.15)
876
+ await cancel_entry_orders_for_coin(
877
+ info,
878
+ exchange,
879
+ account_address,
880
+ coin,
881
+ is_buy=is_buy,
882
+ label="entry order reconcile after market fallback",
883
+ )
884
+ except Exception as exc:
885
+ raise RuntimeError(f"Market fallback failed: {exc}") from exc
886
+ else:
887
+ await cancel_active_entry_order("unfilled entry order after limit attempts", reconcile=True)
888
+ raise RuntimeError(
889
+ f"Limit entry did not fully fill. Filled {filled:.8f} of {size:.8f}; market fallback disabled."
890
+ )
891
+
892
+ await asyncio.sleep(max(0.25, repost_interval))
893
+ final_pos = await get_position_for_coin(info, account_address, coin)
894
+ if final_pos is None:
895
+ raise RuntimeError(f"No open {coin} position found after entry attempts.")
896
+
897
+ final_size = float(final_pos.get("szi", "0"))
898
+ if is_buy and final_size <= initial_pos:
899
+ raise RuntimeError(f"Expected larger/longer {coin} position after buy entry, got {final_size}.")
900
+ if (not is_buy) and final_size >= initial_pos:
901
+ raise RuntimeError(f"Expected smaller/shorter {coin} position after sell entry, got {final_size}.")
902
+
903
+ return final_pos
904
+
905
+
906
+ async def place_stop_market_order(
907
+ info: Info,
908
+ exchange: Exchange,
909
+ coin: str,
910
+ side: str,
911
+ position_size_abs: float,
912
+ trigger_px: float,
913
+ slippage: float,
914
+ label: str = "SL",
915
+ ) -> Tuple[Optional[int], float]:
916
+ """Place a reduce-only stop-market order for the full managed position."""
917
+ size = await round_size_for_hyperliquid(info, coin, position_size_abs)
918
+ is_exit_buy = side == "short"
919
+ if side not in ("long", "short"):
920
+ raise RuntimeError(f"Invalid side: {side}")
921
+
922
+ trigger_px = await round_price_for_hyperliquid(info, coin, trigger_px)
923
+ limit_px = await stop_limit_px_for_trigger(info, coin, is_exit_buy, trigger_px, slippage)
924
+ order_type = {"trigger": {"triggerPx": trigger_px, "isMarket": True, "tpsl": "sl"}}
925
+ side_label = "BUY" if is_exit_buy else "SELL"
926
+
927
+ print(
928
+ f"[{label}] Placing reduce-only stop-market {side_label} {size:.8f} {coin}: "
929
+ f"trigger={trigger_px:.8f}, limit_px={limit_px:.8f}"
930
+ )
931
+ resp = await exchange.order(coin, is_exit_buy, size, limit_px, order_type, reduce_only=True)
932
+ print(f"[{label}] response: {resp}")
933
+ oids = extract_resting_oids(resp)
934
+ return (oids[0] if oids else None), trigger_px
935
+
936
+
937
+ async def compute_auto_sar_stop_trigger_px(
938
+ info: Info,
939
+ coin: str,
940
+ side: str,
941
+ interval: str,
942
+ periods: int,
943
+ sar_acceleration: float,
944
+ sar_maximum: float,
945
+ use_last_closed_candle: bool,
946
+ use_websocket_candles: bool = False,
947
+ ) -> Optional[float]:
948
+ """Compute the latest protective SAR stop for an active auto-managed position."""
949
+ sar_stop_px, _, _, _ = await compute_auto_sar_stop_state(
950
+ info=info,
951
+ coin=coin,
952
+ side=side,
953
+ interval=interval,
954
+ periods=periods,
955
+ sar_acceleration=sar_acceleration,
956
+ sar_maximum=sar_maximum,
957
+ use_last_closed_candle=use_last_closed_candle,
958
+ use_websocket_candles=use_websocket_candles,
959
+ )
960
+ return sar_stop_px
961
+
962
+
963
+ async def compute_auto_sar_stop_state(
964
+ info: Info,
965
+ coin: str,
966
+ side: str,
967
+ interval: str,
968
+ periods: int,
969
+ sar_acceleration: float,
970
+ sar_maximum: float,
971
+ use_last_closed_candle: bool,
972
+ use_websocket_candles: bool = False,
973
+ ) -> Tuple[Optional[float], Optional[float], Optional[float], bool]:
974
+ """Return the latest SAR stop candidate and whether the SAR flipped against the position."""
975
+ if np is None or talib is None:
976
+ raise RuntimeError("Dynamic auto SAR stop updates require numpy and TA-Lib.")
977
+
978
+ candles = await fetch_recent_candles(
979
+ info,
980
+ coin,
981
+ interval,
982
+ periods,
983
+ use_websocket_candles=use_websocket_candles,
984
+ )
985
+ usable_candles = candles[:-1] if use_last_closed_candle and len(candles) > 1 else candles
986
+ highs: List[float] = []
987
+ lows: List[float] = []
988
+ closes: List[float] = []
989
+ for candle in usable_candles:
990
+ try:
991
+ highs.append(float(candle["h"]))
992
+ lows.append(float(candle["l"]))
993
+ closes.append(float(candle["c"]))
994
+ except (KeyError, TypeError, ValueError):
995
+ continue
996
+
997
+ if not highs or not lows or not closes:
998
+ raise RuntimeError(f"No valid candles available to compute SAR stop for {coin} {interval}.")
999
+
1000
+ high_arr = np.asarray(highs, dtype=float) # type: ignore[union-attr]
1001
+ low_arr = np.asarray(lows, dtype=float) # type: ignore[union-attr]
1002
+ close_arr = np.asarray(closes, dtype=float) # type: ignore[union-attr]
1003
+ sar_arr = talib.SAR( # type: ignore[union-attr]
1004
+ high_arr,
1005
+ low_arr,
1006
+ acceleration=sar_acceleration,
1007
+ maximum=sar_maximum,
1008
+ )
1009
+
1010
+ for idx in range(len(sar_arr) - 1, -1, -1):
1011
+ sar_value = sar_arr[idx]
1012
+ close_value = close_arr[idx]
1013
+ if not np.isfinite(sar_value) or not np.isfinite(close_value): # type: ignore[union-attr]
1014
+ continue
1015
+
1016
+ candidate = float(sar_value)
1017
+ close_px = float(close_value)
1018
+ if side == "long" and candidate < close_px:
1019
+ return candidate, candidate, close_px, False
1020
+ if side == "short" and candidate > close_px:
1021
+ return candidate, candidate, close_px, False
1022
+ if side == "long":
1023
+ return None, candidate, close_px, candidate >= close_px
1024
+ if side == "short":
1025
+ return None, candidate, close_px, candidate <= close_px
1026
+ raise RuntimeError(f"Invalid side: {side}")
1027
+
1028
+ return None, None, None, False
1029
+
1030
+
1031
+ async def monitor_bracket_position(
1032
+ info: Info,
1033
+ exchange: Exchange,
1034
+ account_address: str,
1035
+ coin: str,
1036
+ side: str,
1037
+ entry_px: float,
1038
+ take_profit_pct: Optional[float],
1039
+ tp_orders: List[Dict[str, Any]],
1040
+ tp_oids: List[int],
1041
+ stop_oid: Optional[int],
1042
+ managed_signed_size: float,
1043
+ poll_interval: float,
1044
+ reversal_pct: Optional[float],
1045
+ market_slippage: float,
1046
+ stop_loss_pct: Optional[float],
1047
+ stop_loss_trigger_px: Optional[float],
1048
+ take_profit_levels: int,
1049
+ tp_tif: str,
1050
+ tp_reversal_limit_exit: bool,
1051
+ tp_reversal_stop_buffer_pct: Optional[float],
1052
+ hide_orders: bool = False,
1053
+ use_trailing_tp: bool = False,
1054
+ trailing_tp_trigger_level: int = 1,
1055
+ trailing_tp_profit_pct: float = 0.25,
1056
+ metrics_start_time_ms: Optional[int] = None,
1057
+ auto_sar_stop_interval: Optional[str] = None,
1058
+ auto_sar_stop_periods: Optional[int] = None,
1059
+ auto_sar_acceleration: Optional[float] = None,
1060
+ auto_sar_maximum: Optional[float] = None,
1061
+ auto_use_last_closed_candle: bool = True,
1062
+ auto_use_websocket_candles: bool = False,
1063
+ ) -> None:
1064
+ """Monitor bracket orders, display PnL/account metrics, and manage hidden/public TP/SL."""
1065
+ if poll_interval <= 0.0:
1066
+ raise RuntimeError("poll_interval must be > 0")
1067
+ tp_reversal_enabled = reversal_pct is not None
1068
+ if tp_reversal_enabled and not (0.0 < reversal_pct < 1.0):
1069
+ raise RuntimeError("reversal_pct must be a decimal fraction between 0 and 1.")
1070
+ if trailing_tp_trigger_level <= 0:
1071
+ raise RuntimeError("trailing_tp_trigger_level must be > 0.")
1072
+
1073
+ side_is_long = side == "long"
1074
+ tp_trigger_px: Optional[float] = None
1075
+ if tp_orders:
1076
+ prices = [float(order["px"]) for order in tp_orders]
1077
+ tp_trigger_px = min(prices) if side_is_long else max(prices)
1078
+
1079
+ hidden_tp_placed_levels: set[int] = set()
1080
+ hidden_tp_oids: Dict[int, List[int]] = {}
1081
+ tp_level_to_oid = build_tp_level_to_oid_map(tp_orders, tp_oids)
1082
+ local_stop_px: Optional[float] = None
1083
+ stop_source = "none"
1084
+ trailing_tp_armed = False
1085
+ trailing_tp_stop_px: Optional[float] = None
1086
+ dynamic_auto_sar_stop_enabled = (
1087
+ auto_sar_stop_interval is not None
1088
+ and auto_sar_stop_periods is not None
1089
+ and auto_sar_acceleration is not None
1090
+ and auto_sar_maximum is not None
1091
+ )
1092
+ if hide_orders:
1093
+ local_stop_px, stop_source = resolve_stop_loss_trigger_px(side, entry_px, stop_loss_pct, stop_loss_trigger_px)
1094
+
1095
+ print("============================================================")
1096
+ print(" Async Bracket Position Monitor")
1097
+ print("============================================================")
1098
+ print(f"Coin: {coin}")
1099
+ print(f"Side: {side}")
1100
+ print(f"Entry: {entry_px:.8f}")
1101
+ print(f"Hide orders: {hide_orders}")
1102
+ print(f"First TP px: {tp_trigger_px if tp_trigger_px is not None else 'N/A'}")
1103
+ print(f"Trailing TP: {use_trailing_tp}")
1104
+ if use_trailing_tp:
1105
+ print(f"Trailing TP level: {trailing_tp_trigger_level}")
1106
+ print(f"Trailing TP pct: {trailing_tp_profit_pct * 100:.4f}%")
1107
+ print(
1108
+ f"Local stop px: {fmt_optional_float(local_stop_px)} ({stop_source})"
1109
+ if hide_orders
1110
+ else f"Stop oid: {stop_oid if stop_oid is not None else 'N/A'}"
1111
+ )
1112
+ if hide_orders and not use_trailing_tp:
1113
+ print(f"Hidden TP targets: {format_tp_targets(tp_orders, hidden_tp_placed_levels)}")
1114
+ if tp_reversal_enabled:
1115
+ print(f"TP reversal pct: {reversal_pct * 100:.4f}%")
1116
+ else:
1117
+ print("TP reversal pct: DISABLED")
1118
+ if tp_reversal_enabled and tp_reversal_limit_exit:
1119
+ stop_buffer_pct = resolve_tp_reversal_stop_buffer_pct(reversal_pct, tp_reversal_stop_buffer_pct)
1120
+ print("TP rev exit mode: LIMIT+STOP then MARKET")
1121
+ print(f"TP rev stop pct: {stop_buffer_pct * 100:.4f}%")
1122
+ elif tp_reversal_enabled:
1123
+ print("TP rev exit mode: MARKET")
1124
+ else:
1125
+ print("TP rev exit mode: DISABLED")
1126
+ print(f"TP resting oids: {tp_oids}")
1127
+ print(f"Poll interval: {poll_interval:.2f}s")
1128
+ if dynamic_auto_sar_stop_enabled:
1129
+ print(
1130
+ f"Auto SAR stop: {auto_sar_stop_interval} "
1131
+ f"(periods={auto_sar_stop_periods}, closed_only={auto_use_last_closed_candle})"
1132
+ )
1133
+ print(f"Auto SAR WS: {auto_use_websocket_candles}")
1134
+ print("------------------------------------------------------------")
1135
+ print("Press Ctrl+C to stop monitoring without closing the position.")
1136
+ print("============================================================")
1137
+
1138
+ favorable_extreme: Optional[float] = None
1139
+ tp_zone_seen = False
1140
+ tp_remainder_close_in_progress = False
1141
+
1142
+ try:
1143
+ while True:
1144
+ await asyncio.sleep(poll_interval)
1145
+ try:
1146
+ pos = await get_position_for_coin(info, account_address, coin)
1147
+ if pos is None:
1148
+ print(f"[DONE] No open {coin} position remains. Canceling leftover reduce-only orders.")
1149
+ await cancel_reduce_only_orders_for_coin(info, exchange, account_address, coin, only_tpsl=False)
1150
+ return
1151
+
1152
+ try:
1153
+ _, live_signed_size, live_entry_px, live_side, live_pos_abs = parse_position_snapshot(pos)
1154
+ except RuntimeError as exc:
1155
+ print(f"[WARN] {exc}")
1156
+ continue
1157
+
1158
+ if side_is_long and live_signed_size <= 0.0:
1159
+ print(f"[DONE] Managed long {coin} is no longer open. Canceling leftovers.")
1160
+ await cancel_reduce_only_orders_for_coin(info, exchange, account_address, coin, only_tpsl=False)
1161
+ return
1162
+ if (not side_is_long) and live_signed_size >= 0.0:
1163
+ print(f"[DONE] Managed short {coin} is no longer open. Canceling leftovers.")
1164
+ await cancel_reduce_only_orders_for_coin(info, exchange, account_address, coin, only_tpsl=False)
1165
+ return
1166
+
1167
+ mids = await get_all_mids(info)
1168
+ if coin not in mids:
1169
+ print(f"[WARN] No mid price for {coin}; skipping this monitor tick.")
1170
+ continue
1171
+ price = float(mids[coin])
1172
+ unrealized_pnl = compute_position_unrealized_pnl(pos, price)
1173
+ metrics = await get_account_runtime_metrics(info, account_address, metrics_start_time_ms, coin=coin)
1174
+
1175
+ size_increased = position_is_directional_add(managed_signed_size, live_signed_size)
1176
+ if size_increased:
1177
+ print(
1178
+ f"[REBASE] {coin} position increased from {managed_signed_size:.8f} to {live_signed_size:.8f}. "
1179
+ f"Rebuilding {'hidden ' if hide_orders else ''}TP/SL from avg entry {live_entry_px:.8f}."
1180
+ )
1181
+ stop_oid, tp_orders, tp_oids, tp_trigger_px = await rebuild_bracket_orders(
1182
+ info=info,
1183
+ exchange=exchange,
1184
+ account_address=account_address,
1185
+ coin=coin,
1186
+ side=live_side,
1187
+ position_size_abs=live_pos_abs,
1188
+ entry_px=live_entry_px,
1189
+ take_profit_pct=take_profit_pct,
1190
+ stop_loss_pct=stop_loss_pct,
1191
+ stop_loss_trigger_px=stop_loss_trigger_px,
1192
+ take_profit_levels=take_profit_levels,
1193
+ tp_tif=tp_tif,
1194
+ market_slippage=market_slippage,
1195
+ cancel_existing_reduce_only=True,
1196
+ hide_orders=hide_orders,
1197
+ )
1198
+ side = live_side
1199
+ side_is_long = side == "long"
1200
+ entry_px = live_entry_px
1201
+ managed_signed_size = live_signed_size
1202
+ favorable_extreme = price
1203
+ tp_zone_seen = tp_trigger_px is not None and (
1204
+ price >= tp_trigger_px if side_is_long else price <= tp_trigger_px
1205
+ )
1206
+ trailing_tp_armed = False
1207
+ trailing_tp_stop_px = None
1208
+ hidden_tp_placed_levels.clear()
1209
+ hidden_tp_oids.clear()
1210
+ tp_level_to_oid = build_tp_level_to_oid_map(tp_orders, tp_oids)
1211
+ tp_remainder_close_in_progress = False
1212
+ if hide_orders:
1213
+ local_stop_px, stop_source = resolve_stop_loss_trigger_px(
1214
+ side,
1215
+ entry_px,
1216
+ stop_loss_pct,
1217
+ stop_loss_trigger_px,
1218
+ )
1219
+ else:
1220
+ managed_signed_size = live_signed_size
1221
+ entry_px = live_entry_px
1222
+
1223
+ if dynamic_auto_sar_stop_enabled:
1224
+ try:
1225
+ updated_sar_stop_px, latest_sar_px, latest_sar_close_px, sar_flip_exit = await compute_auto_sar_stop_state(
1226
+ info=info,
1227
+ coin=coin,
1228
+ side=side,
1229
+ interval=auto_sar_stop_interval,
1230
+ periods=auto_sar_stop_periods,
1231
+ sar_acceleration=auto_sar_acceleration,
1232
+ sar_maximum=auto_sar_maximum,
1233
+ use_last_closed_candle=auto_use_last_closed_candle,
1234
+ use_websocket_candles=auto_use_websocket_candles,
1235
+ )
1236
+ except Exception as exc:
1237
+ print(f"[SL-SAR-WARN] Failed to refresh SAR stop for {coin}: {exc}")
1238
+ else:
1239
+ if sar_flip_exit:
1240
+ print(
1241
+ f"[SL-SAR] {coin} SAR flipped against {side} position: "
1242
+ f"sar={fmt_optional_float(latest_sar_px)} close={fmt_optional_float(latest_sar_close_px)}. "
1243
+ "Canceling reduce-only orders and market-closing."
1244
+ )
1245
+ await cancel_reduce_only_orders_for_coin(
1246
+ info,
1247
+ exchange,
1248
+ account_address,
1249
+ coin,
1250
+ only_tpsl=False,
1251
+ )
1252
+ try:
1253
+ resp = await exchange.market_close(coin, slippage=market_slippage)
1254
+ print(f"[SL-SAR] market_close response: {resp}")
1255
+ except Exception as exc:
1256
+ print(f"[ERROR] Auto SAR market_close failed for {coin}: {exc}")
1257
+ continue
1258
+ return
1259
+ if updated_sar_stop_px is not None:
1260
+ rounded_sar_stop_px = await round_price_for_hyperliquid(info, coin, updated_sar_stop_px)
1261
+ if hide_orders:
1262
+ if local_stop_px is None or abs(rounded_sar_stop_px - local_stop_px) > PRICE_EPS:
1263
+ print(
1264
+ f"[SL-SAR] Updating hidden SAR stop for {coin}: "
1265
+ f"{fmt_optional_float(local_stop_px)} -> {rounded_sar_stop_px:.8f}"
1266
+ )
1267
+ local_stop_px = rounded_sar_stop_px
1268
+ stop_source = "sar-dynamic"
1269
+ else:
1270
+ current_stop_px = None if stop_oid is None else stop_loss_trigger_px
1271
+ if current_stop_px is None or abs(rounded_sar_stop_px - current_stop_px) > PRICE_EPS:
1272
+ print(
1273
+ f"[SL-SAR] Updating exchange stop for {coin}: "
1274
+ f"{fmt_optional_float(current_stop_px)} -> {rounded_sar_stop_px:.8f}"
1275
+ )
1276
+ await cancel_reduce_only_orders_for_coin(
1277
+ info,
1278
+ exchange,
1279
+ account_address,
1280
+ coin,
1281
+ only_tpsl=True,
1282
+ )
1283
+ stop_oid, stop_loss_trigger_px = await place_stop_market_order(
1284
+ info=info,
1285
+ exchange=exchange,
1286
+ coin=coin,
1287
+ side=side,
1288
+ position_size_abs=live_pos_abs,
1289
+ trigger_px=rounded_sar_stop_px,
1290
+ slippage=market_slippage,
1291
+ label="SL-SAR",
1292
+ )
1293
+ elif hide_orders:
1294
+ stop_source = "sar-dynamic"
1295
+
1296
+ if side_is_long:
1297
+ if favorable_extreme is None or price > favorable_extreme:
1298
+ favorable_extreme = price
1299
+ if tp_trigger_px is not None and price >= tp_trigger_px:
1300
+ tp_zone_seen = True
1301
+ current_tp_reversal_px = (
1302
+ compute_tp_reversal_trigger_px(side, entry_px, favorable_extreme, reversal_pct)
1303
+ if tp_reversal_enabled and reversal_pct is not None and tp_zone_seen and favorable_extreme is not None
1304
+ else None
1305
+ )
1306
+ if current_tp_reversal_px is not None:
1307
+ current_tp_reversal_px = clamp_tp_reversal_trigger_px(side, entry_px, current_tp_reversal_px)
1308
+ reversal_hit = current_tp_reversal_px is not None and price <= current_tp_reversal_px
1309
+ else:
1310
+ if favorable_extreme is None or price < favorable_extreme:
1311
+ favorable_extreme = price
1312
+ if tp_trigger_px is not None and price <= tp_trigger_px:
1313
+ tp_zone_seen = True
1314
+ current_tp_reversal_px = (
1315
+ compute_tp_reversal_trigger_px(side, entry_px, favorable_extreme, reversal_pct)
1316
+ if tp_reversal_enabled and reversal_pct is not None and tp_zone_seen and favorable_extreme is not None
1317
+ else None
1318
+ )
1319
+ if current_tp_reversal_px is not None:
1320
+ current_tp_reversal_px = clamp_tp_reversal_trigger_px(side, entry_px, current_tp_reversal_px)
1321
+ reversal_hit = current_tp_reversal_px is not None and price >= current_tp_reversal_px
1322
+
1323
+ if hide_orders:
1324
+ if stop_source == "pct":
1325
+ local_stop_px = update_hidden_stop_from_favorable_extreme(
1326
+ side=side,
1327
+ entry_px=entry_px,
1328
+ current_stop_px=local_stop_px,
1329
+ favorable_extreme=favorable_extreme,
1330
+ stop_loss_pct=stop_loss_pct,
1331
+ )
1332
+
1333
+ if price_hit_stop(side, price, local_stop_px):
1334
+ print(
1335
+ f"[HIDDEN-SL] {coin} local stop hit at mid={price:.8f}; "
1336
+ f"stop={local_stop_px:.8f}. Canceling TP orders and market-closing."
1337
+ )
1338
+ await cancel_reduce_only_orders_for_coin(info, exchange, account_address, coin, only_tpsl=False)
1339
+ try:
1340
+ resp = await exchange.market_close(coin, slippage=market_slippage)
1341
+ print(f"[HIDDEN-SL] market_close response: {resp}")
1342
+ except Exception as exc:
1343
+ print(f"[ERROR] Hidden stop market_close failed for {coin}: {exc}")
1344
+ continue
1345
+ return
1346
+
1347
+ if not use_trailing_tp:
1348
+ for order in tp_orders:
1349
+ level = int(order.get("level", 0))
1350
+ if level in hidden_tp_placed_levels:
1351
+ continue
1352
+ target_px = float(order["px"])
1353
+ if not price_hit_take_profit(side, price, target_px):
1354
+ continue
1355
+ oids = await place_hidden_take_profit_order(info, exchange, coin, side, order, tp_tif)
1356
+ if oids:
1357
+ hidden_tp_placed_levels.add(level)
1358
+ hidden_tp_oids[level] = oids
1359
+ tp_oids.extend(oids)
1360
+
1361
+ if ((use_trailing_tp and hide_orders and tp_trigger_px is not None) or trailing_tp_armed):
1362
+ if not trailing_tp_armed and tp_zone_seen:
1363
+ trailing_tp_armed = True
1364
+ trailing_tp_stop_px = compute_trailing_take_profit_stop_px(
1365
+ side=side,
1366
+ entry_px=entry_px,
1367
+ favorable_extreme=favorable_extreme,
1368
+ trail_profit_pct=trailing_tp_profit_pct,
1369
+ current_stop_px=trailing_tp_stop_px,
1370
+ )
1371
+ print(
1372
+ f"[TRAILING-TP] {coin} first TP zone reached at mid={price:.8f}; "
1373
+ f"initial trailing TP stop={fmt_optional_float(trailing_tp_stop_px)}"
1374
+ )
1375
+
1376
+ if trailing_tp_armed:
1377
+ new_trailing_tp_stop_px = compute_trailing_take_profit_stop_px(
1378
+ side=side,
1379
+ entry_px=entry_px,
1380
+ favorable_extreme=favorable_extreme,
1381
+ trail_profit_pct=trailing_tp_profit_pct,
1382
+ current_stop_px=trailing_tp_stop_px,
1383
+ )
1384
+ if new_trailing_tp_stop_px is not None and (
1385
+ trailing_tp_stop_px is None or abs(new_trailing_tp_stop_px - trailing_tp_stop_px) > PRICE_EPS
1386
+ ):
1387
+ print(
1388
+ f"[TRAILING-TP] {coin} ratcheting trailing TP stop "
1389
+ f"{fmt_optional_float(trailing_tp_stop_px)} -> {new_trailing_tp_stop_px:.8f}"
1390
+ )
1391
+ trailing_tp_stop_px = new_trailing_tp_stop_px
1392
+
1393
+ if price_hit_stop(side, price, trailing_tp_stop_px):
1394
+ print(
1395
+ f"[TRAILING-TP] {coin} trailing TP hit at mid={price:.8f}; "
1396
+ f"stop={fmt_optional_float(trailing_tp_stop_px)}. Canceling reduce-only orders and market-closing."
1397
+ )
1398
+ await cancel_reduce_only_orders_for_coin(info, exchange, account_address, coin, only_tpsl=False)
1399
+ try:
1400
+ resp = await exchange.market_close(coin, slippage=market_slippage)
1401
+ print(f"[TRAILING-TP] market_close response: {resp}")
1402
+ except Exception as exc:
1403
+ print(f"[ERROR] Trailing TP market_close failed for {coin}: {exc}")
1404
+ continue
1405
+ return
1406
+
1407
+ if tp_orders and not use_trailing_tp:
1408
+ open_tp_orders = await get_open_reduce_only_take_profit_orders_for_coin(
1409
+ info,
1410
+ account_address,
1411
+ coin,
1412
+ )
1413
+ tp_levels_exhausted = False
1414
+ if hide_orders:
1415
+ tp_levels_exhausted = (
1416
+ len(hidden_tp_placed_levels) >= len(tp_orders)
1417
+ and len(open_tp_orders) == 0
1418
+ )
1419
+ else:
1420
+ tp_levels_exhausted = len(open_tp_orders) == 0 and not trailing_tp_armed
1421
+
1422
+ if tp_levels_exhausted and not tp_remainder_close_in_progress:
1423
+ tp_remainder_close_in_progress = True
1424
+ print(
1425
+ f"[TP-REMAINDER] {coin} take-profit ladder is exhausted; "
1426
+ "checking whether any position remains open."
1427
+ )
1428
+ await cancel_reduce_only_orders_for_coin(info, exchange, account_address, coin, only_tpsl=False)
1429
+ await asyncio.sleep(min(max(poll_interval * 0.25, 0.15), 0.5))
1430
+ remainder_pos = await get_position_for_coin(info, account_address, coin)
1431
+ if remainder_pos is None:
1432
+ print(f"[TP-REMAINDER] {coin} is already flat after TP cleanup.")
1433
+ return
1434
+ try:
1435
+ _, remainder_signed_size, _, remainder_side, remainder_abs = parse_position_snapshot(
1436
+ remainder_pos
1437
+ )
1438
+ except RuntimeError as exc:
1439
+ print(f"[TP-REMAINDER] {coin} remainder check could not parse live position: {exc}")
1440
+ return
1441
+
1442
+ if remainder_side != side or remainder_abs <= 0.0:
1443
+ print(f"[TP-REMAINDER] {coin} no longer has a managed residual position.")
1444
+ return
1445
+
1446
+ print(
1447
+ f"[TP-REMAINDER] Residual {coin} position detected after all TP levels: "
1448
+ f"signed={remainder_signed_size:.8f}. Closing remainder with market_close."
1449
+ )
1450
+ try:
1451
+ resp = await exchange.market_close(coin, slippage=market_slippage)
1452
+ print(f"[TP-REMAINDER] market_close response: {resp}")
1453
+ except Exception as exc:
1454
+ print(f"[ERROR] TP remainder market_close failed for {coin}: {exc}")
1455
+ tp_remainder_close_in_progress = False
1456
+ continue
1457
+
1458
+ await asyncio.sleep(min(max(poll_interval * 0.5, 0.25), 1.0))
1459
+ final_remainder_pos = await get_position_for_coin(info, account_address, coin)
1460
+ if final_remainder_pos is None:
1461
+ print(f"[TP-REMAINDER] {coin} fully closed after residual market exit.")
1462
+ return
1463
+ print(f"[TP-REMAINDER-WARN] {coin} still appears open after residual market exit; retrying monitor.")
1464
+ tp_remainder_close_in_progress = False
1465
+
1466
+ if tp_orders and use_trailing_tp and not hide_orders and not trailing_tp_armed:
1467
+ open_tp_orders = await get_open_reduce_only_take_profit_orders_for_coin(
1468
+ info,
1469
+ account_address,
1470
+ coin,
1471
+ )
1472
+ open_tp_oids = {
1473
+ int(order["oid"])
1474
+ for order in open_tp_orders
1475
+ if isinstance(order, dict) and isinstance(order.get("oid"), int)
1476
+ }
1477
+ trigger_tp_oid = tp_level_to_oid.get(trailing_tp_trigger_level)
1478
+ if trigger_tp_oid is not None and trigger_tp_oid not in open_tp_oids:
1479
+ remaining_tp_oids = sorted(open_tp_oids)
1480
+ print(
1481
+ f"[TRAILING-TP] {coin} TP level {trailing_tp_trigger_level} filled. "
1482
+ "Canceling remaining TP ladder orders and switching to a local trailing TP."
1483
+ )
1484
+ if remaining_tp_oids:
1485
+ await cancel_oids(
1486
+ exchange,
1487
+ coin,
1488
+ remaining_tp_oids,
1489
+ "remaining TP orders before trailing conversion",
1490
+ )
1491
+ await asyncio.sleep(min(max(poll_interval * 0.25, 0.15), 0.5))
1492
+ trailing_tp_armed = True
1493
+ trailing_tp_stop_px = compute_trailing_take_profit_stop_px(
1494
+ side=side,
1495
+ entry_px=entry_px,
1496
+ favorable_extreme=favorable_extreme,
1497
+ trail_profit_pct=trailing_tp_profit_pct,
1498
+ current_stop_px=trailing_tp_stop_px,
1499
+ )
1500
+ print(
1501
+ f"[TRAILING-TP] {coin} trailing TP armed after TP level "
1502
+ f"{trailing_tp_trigger_level} fill; initial stop={fmt_optional_float(trailing_tp_stop_px)}"
1503
+ )
1504
+
1505
+ extreme_str = f"{favorable_extreme:.8f}" if favorable_extreme is not None else "N/A"
1506
+ pnl_str = f"{unrealized_pnl:.8f}" if unrealized_pnl is not None else "N/A"
1507
+ tp_reversal_str = f"{current_tp_reversal_px:.8f}" if current_tp_reversal_px is not None else "N/A"
1508
+ stop_str = fmt_optional_float(local_stop_px) if hide_orders else (
1509
+ str(stop_oid) if stop_oid is not None else "N/A")
1510
+ if use_trailing_tp or trailing_tp_armed:
1511
+ tp_targets_str = (
1512
+ f"trail-armed:{fmt_optional_float(trailing_tp_stop_px)}"
1513
+ if trailing_tp_armed
1514
+ else f"trail-pending:L{trailing_tp_trigger_level}"
1515
+ )
1516
+ else:
1517
+ tp_targets_str = (
1518
+ format_tp_targets(tp_orders, hidden_tp_placed_levels)
1519
+ if hide_orders
1520
+ else "exchange-resting"
1521
+ )
1522
+ print(
1523
+ f"[MONITOR] {coin} side={side} pos={managed_signed_size:.8f} mid={price:.8f} "
1524
+ f"entry={entry_px:.8f} upnl={pnl_str} {format_account_metrics(metrics)} "
1525
+ f"extreme={extreme_str} local_stop={stop_str} tp_zone_seen={tp_zone_seen} "
1526
+ f"tp_rev_px={tp_reversal_str} tp_targets={tp_targets_str}"
1527
+ )
1528
+
1529
+ if reversal_hit:
1530
+ print(
1531
+ f"[TP-REVERSAL] {coin} moved into TP zone and reversed by "
1532
+ f"{reversal_pct * 100:.4f}%. Canceling TP/SL orders and exiting."
1533
+ )
1534
+ await cancel_reduce_only_orders_for_coin(info, exchange, account_address, coin, only_tpsl=False)
1535
+ exit_ok = await exit_on_tp_reversal(
1536
+ info=info,
1537
+ exchange=exchange,
1538
+ account_address=account_address,
1539
+ coin=coin,
1540
+ side=side,
1541
+ live_signed_size=managed_signed_size,
1542
+ reference_px=price,
1543
+ reversal_pct=reversal_pct,
1544
+ market_slippage=market_slippage,
1545
+ poll_interval=poll_interval,
1546
+ limit_exit_first=(tp_reversal_limit_exit and not hide_orders),
1547
+ stop_buffer_pct=tp_reversal_stop_buffer_pct,
1548
+ )
1549
+ if not exit_ok:
1550
+ continue
1551
+ await asyncio.sleep(max(0.25, poll_interval))
1552
+ await cancel_reduce_only_orders_for_coin(info, exchange, account_address, coin, only_tpsl=False)
1553
+ return
1554
+ except Exception as exc:
1555
+ if is_rate_limit_error(exc):
1556
+ print(
1557
+ f"[WATCH-RETRY] {coin} monitor hit Hyperliquid rate limit ({exc}). "
1558
+ f"Sleeping {WATCH_RETRY_SLEEP_SECONDS:.1f}s before retry."
1559
+ )
1560
+ await asyncio.sleep(WATCH_RETRY_SLEEP_SECONDS)
1561
+ continue
1562
+ raise
1563
+ except KeyboardInterrupt:
1564
+ print("\n[!] Caught Ctrl+C, leaving current open orders/position untouched and exiting monitor.")
1565
+
1566
+
1567
+ async def place_take_profit_ladder(
1568
+ exchange: Exchange,
1569
+ coin: str,
1570
+ tp_orders: List[Dict[str, Any]],
1571
+ tp_tif: str,
1572
+ ) -> List[int]:
1573
+ """Place reduce-only TP limit orders and return any resting oids."""
1574
+ if tp_tif not in ("Alo", "Gtc"):
1575
+ raise RuntimeError("tp_tif must be Alo or Gtc.")
1576
+ if not tp_orders:
1577
+ return []
1578
+
1579
+ requests_payload: List[Dict[str, Any]] = []
1580
+ print("[TP] Planned take-profit ladder:")
1581
+ for order in tp_orders:
1582
+ side = "BUY" if order["is_buy"] else "SELL"
1583
+ print(f" L{order['level']:02d} {side:4} {order['sz']:.8f} {coin} @ {order['px']:.8f}")
1584
+ requests_payload.append(
1585
+ {
1586
+ "coin": coin,
1587
+ "is_buy": bool(order["is_buy"]),
1588
+ "sz": float(order["sz"]),
1589
+ "limit_px": float(order["px"]),
1590
+ "order_type": {"limit": {"tif": tp_tif}},
1591
+ "reduce_only": True,
1592
+ }
1593
+ )
1594
+
1595
+ resp = await exchange.bulk_orders(requests_payload)
1596
+ print(f"[TP] bulk_orders response: {resp}")
1597
+ return extract_resting_oids(resp)
1598
+
1599
+
1600
+ async def build_take_profit_ladder(
1601
+ info: Info,
1602
+ coin: str,
1603
+ side: str,
1604
+ position_size_abs: float,
1605
+ entry_px: float,
1606
+ take_profit_pct: float,
1607
+ levels: int,
1608
+ ) -> List[Dict[str, Any]]:
1609
+ """Build weighted TP limit orders that sum to the full position."""
1610
+ if not (0.0 < take_profit_pct < 1.0):
1611
+ raise RuntimeError("take_profit_pct must be a decimal fraction between 0 and 1.")
1612
+ if levels <= 0:
1613
+ raise RuntimeError("take-profit levels must be > 0")
1614
+
1615
+ exit_is_buy = side == "short"
1616
+ weights = list(range(1, levels + 1))
1617
+ weight_sum = float(sum(weights))
1618
+ raw_sizes = [position_size_abs * (weight / weight_sum) for weight in weights]
1619
+
1620
+ orders: List[Dict[str, Any]] = []
1621
+ remaining = await round_size_for_hyperliquid(info, coin, position_size_abs)
1622
+
1623
+ for idx, raw_sz in enumerate(raw_sizes, start=1):
1624
+ if idx == levels:
1625
+ sz = remaining
1626
+ else:
1627
+ sz = min(remaining, await round_size_for_hyperliquid(info, coin, raw_sz))
1628
+ remaining = max(0.0, remaining - sz)
1629
+ if sz <= 0.0:
1630
+ continue
1631
+
1632
+ fraction = idx / levels
1633
+ raw_px = entry_px * (1.0 + take_profit_pct * fraction) if side == "long" else entry_px * (
1634
+ 1.0 - take_profit_pct * fraction)
1635
+ px = await round_price_for_hyperliquid(info, coin, raw_px)
1636
+ orders.append({"coin": coin, "is_buy": exit_is_buy, "sz": sz, "px": px, "level": idx})
1637
+
1638
+ return orders
1639
+
1640
+
1641
+ async def rebuild_bracket_orders(
1642
+ info: Info,
1643
+ exchange: Exchange,
1644
+ account_address: str,
1645
+ coin: str,
1646
+ side: str,
1647
+ position_size_abs: float,
1648
+ entry_px: float,
1649
+ take_profit_pct: Optional[float],
1650
+ stop_loss_pct: Optional[float],
1651
+ stop_loss_trigger_px: Optional[float],
1652
+ take_profit_levels: int,
1653
+ tp_tif: str,
1654
+ market_slippage: float,
1655
+ cancel_existing_reduce_only: bool = True,
1656
+ hide_orders: bool = False,
1657
+ use_trailing_tp: bool = False,
1658
+ trailing_tp_profit_pct: float = 0.25,
1659
+ ) -> Tuple[Optional[int], List[Dict[str, Any]], List[int], Optional[float]]:
1660
+ """Place or prepare reduce-only TP/SL orders for the current live position.
1661
+
1662
+ Default behavior places exchange-side stop-market and TP limit orders.
1663
+ With hide_orders=True, no TP/SL orders are placed during rebuild; targets are
1664
+ calculated and returned so monitor_bracket_position can hold them in memory
1665
+ and trigger them opportunistically.
1666
+ """
1667
+ if cancel_existing_reduce_only:
1668
+ await cancel_reduce_only_orders_for_coin(info, exchange, account_address, coin, only_tpsl=False)
1669
+
1670
+ tp_orders: List[Dict[str, Any]] = []
1671
+ tp_oids: List[int] = []
1672
+ tp_trigger_px: Optional[float] = None
1673
+
1674
+ if take_profit_pct is not None:
1675
+ tp_orders = await build_take_profit_ladder(
1676
+ info=info,
1677
+ coin=coin,
1678
+ side=side,
1679
+ position_size_abs=position_size_abs,
1680
+ entry_px=entry_px,
1681
+ take_profit_pct=take_profit_pct,
1682
+ levels=take_profit_levels,
1683
+ )
1684
+ prices = [float(order["px"]) for order in tp_orders]
1685
+ if prices:
1686
+ tp_trigger_px = min(prices) if side == "long" else max(prices)
1687
+
1688
+ if hide_orders:
1689
+ resolved_stop_px, stop_source = resolve_stop_loss_trigger_px(side, entry_px, stop_loss_pct,
1690
+ stop_loss_trigger_px)
1691
+ stop_desc = f"{resolved_stop_px:.8f}" if resolved_stop_px is not None else "N/A"
1692
+ print("[HIDE-ORDERS] TP/SL orders are private; no exchange-side bracket orders placed.")
1693
+ print(f"[HIDE-ORDERS] Local stop target: {stop_desc} ({stop_source})")
1694
+ if use_trailing_tp and tp_trigger_px is not None:
1695
+ print(
1696
+ f"[TRAILING-TP] Hidden TP ladder suppressed; trailing TP will arm after first TP at "
1697
+ f"{tp_trigger_px:.8f} with trail {trailing_tp_profit_pct * 100:.4f}% of favorable unrealized profit."
1698
+ )
1699
+ else:
1700
+ print(f"[HIDE-ORDERS] Hidden TP targets: {format_tp_targets(tp_orders)}")
1701
+ return None, tp_orders, [], tp_trigger_px
1702
+
1703
+ stop_oid: Optional[int] = None
1704
+ resolved_stop_px, stop_source = resolve_stop_loss_trigger_px(side, entry_px, stop_loss_pct, stop_loss_trigger_px)
1705
+ if resolved_stop_px is not None:
1706
+ stop_oid, _ = await place_stop_market_order(
1707
+ info=info,
1708
+ exchange=exchange,
1709
+ coin=coin,
1710
+ side=side,
1711
+ position_size_abs=position_size_abs,
1712
+ trigger_px=resolved_stop_px,
1713
+ slippage=market_slippage,
1714
+ label="SL" if stop_source == "pct" else "SL-SAR",
1715
+ )
1716
+
1717
+ if use_trailing_tp and hide_orders and tp_trigger_px is not None:
1718
+ print(
1719
+ f"[TRAILING-TP] TP ladder disabled; arming local trailing take-profit after first TP at "
1720
+ f"{tp_trigger_px:.8f} with trail {trailing_tp_profit_pct * 100:.4f}% of favorable unrealized profit."
1721
+ )
1722
+ elif tp_orders:
1723
+ tp_oids = await place_take_profit_ladder(exchange, coin, tp_orders, tp_tif)
1724
+
1725
+ return stop_oid, tp_orders, tp_oids, tp_trigger_px
1726
+
1727
+
1728
+ async def run_bracket_entry(
1729
+ coin: str,
1730
+ direction: str,
1731
+ size: float,
1732
+ take_profit_pct: Optional[float],
1733
+ stop_loss_pct: Optional[float],
1734
+ stop_loss_trigger_px: Optional[float],
1735
+ take_profit_levels: int,
1736
+ use_trailing_tp: bool,
1737
+ trailing_tp_trigger_level: int,
1738
+ trailing_tp_profit_pct: float,
1739
+ entry_retries: int,
1740
+ entry_repost_interval: float,
1741
+ poll_interval: float,
1742
+ tp_reversal_pct: Optional[float],
1743
+ entry_tif: str,
1744
+ tp_tif: str,
1745
+ market_fallback: bool,
1746
+ market_slippage: float,
1747
+ cancel_existing_tpsl: bool,
1748
+ tp_reversal_limit_exit: bool,
1749
+ tp_reversal_stop_buffer_pct: Optional[float],
1750
+ use_testnet: bool,
1751
+ use_websocket: bool = True,
1752
+ hide_orders: bool = False,
1753
+ auto_sar_stop_interval: Optional[str] = None,
1754
+ auto_sar_stop_periods: Optional[int] = None,
1755
+ auto_sar_acceleration: Optional[float] = None,
1756
+ auto_sar_maximum: Optional[float] = None,
1757
+ auto_use_last_closed_candle: bool = True,
1758
+ auto_use_websocket_candles: bool = False,
1759
+ account_address: Optional[str] = None,
1760
+ info: Optional[Info] = None,
1761
+ exchange: Optional[Exchange] = None,
1762
+ ) -> None:
1763
+ """Enter a position and manage bracket protection/exit logic."""
1764
+ direction = direction.lower().strip()
1765
+ if direction not in ("long", "short"):
1766
+ raise RuntimeError("direction must be 'long' or 'short'.")
1767
+ if size <= 0.0:
1768
+ raise RuntimeError("size must be > 0.")
1769
+ if take_profit_pct is None and stop_loss_pct is None and stop_loss_trigger_px is None:
1770
+ raise RuntimeError("Specify --take-profit-pct, --stop-loss-pct, or both.")
1771
+ if take_profit_pct is not None and not (0.0 < take_profit_pct < 1.0):
1772
+ raise RuntimeError("--take-profit-pct must be a decimal fraction between 0 and 1.")
1773
+
1774
+ stop_loss_pct = compute_default_stop_loss_pct(take_profit_pct, stop_loss_pct)
1775
+ if stop_loss_pct is not None and not (0.0 < stop_loss_pct < 1.0):
1776
+ raise RuntimeError("--stop-loss-pct must be a decimal fraction between 0 and 1.")
1777
+ if take_profit_levels <= 0:
1778
+ raise RuntimeError("--take-profit-levels must be > 0.")
1779
+ if trailing_tp_trigger_level <= 0:
1780
+ raise RuntimeError("--trailing-tp-trigger-level must be > 0.")
1781
+ if trailing_tp_trigger_level > take_profit_levels:
1782
+ raise RuntimeError("--trailing-tp-trigger-level cannot exceed --take-profit-levels.")
1783
+ if not (0.0 < trailing_tp_profit_pct < 1.0):
1784
+ raise RuntimeError("--trailing-tp-profit-pct must be a decimal fraction between 0 and 1.")
1785
+ if entry_tif not in ("Alo", "Gtc"):
1786
+ raise RuntimeError("--entry-tif must be Alo or Gtc.")
1787
+ if tp_tif not in ("Alo", "Gtc"):
1788
+ raise RuntimeError("--tp-tif must be Alo or Gtc.")
1789
+
1790
+ owns_clients = account_address is None and info is None and exchange is None
1791
+ if not owns_clients and (account_address is None or info is None or exchange is None):
1792
+ raise RuntimeError("Pass account_address, info, and exchange together when reusing initialized clients.")
1793
+ try:
1794
+ if owns_clients:
1795
+ account_address, info, exchange = await init_clients(use_testnet, use_websocket=use_websocket)
1796
+ metrics_start_time_ms = int(time.time() * 1000)
1797
+ coin = coin.upper()
1798
+ is_buy = direction == "long"
1799
+
1800
+ print("============================================================")
1801
+ print(" Hyperliquid Async Bracket Entry Bot")
1802
+ print("============================================================")
1803
+ print(f"Account: {account_address}")
1804
+ print(f"Network: {'TESTNET' if use_testnet else 'MAINNET'}")
1805
+ print(f"Websocket: {'ENABLED' if use_websocket else 'DISABLED'}")
1806
+ print(f"WS candles: {'ENABLED' if auto_use_websocket_candles else 'DISABLED'}")
1807
+ print(f"Hide orders: {hide_orders}")
1808
+ print(f"Coin: {coin}")
1809
+ print(f"Direction: {direction}")
1810
+ print(f"Size: {size}")
1811
+ if take_profit_pct is not None:
1812
+ print(f"Take profit pct: {take_profit_pct * 100:.4f}%")
1813
+ else:
1814
+ print("Take profit pct: N/A")
1815
+ print(f"Trailing TP: {use_trailing_tp}")
1816
+ if use_trailing_tp:
1817
+ print(f"Trailing TP level: {trailing_tp_trigger_level}")
1818
+ print(f"Trailing TP pct: {trailing_tp_profit_pct * 100:.4f}%")
1819
+ if stop_loss_pct is not None:
1820
+ print(f"Stop loss pct: {stop_loss_pct * 100:.4f}%")
1821
+ else:
1822
+ print("Stop loss pct: N/A")
1823
+ if stop_loss_trigger_px is not None:
1824
+ print(f"Stop trigger px: {stop_loss_trigger_px:.8f}")
1825
+ print(f"TP levels: {take_profit_levels}")
1826
+ print("============================================================")
1827
+
1828
+ if cancel_existing_tpsl:
1829
+ await cancel_reduce_only_orders_for_coin(info, exchange, account_address, coin, only_tpsl=False)
1830
+
1831
+ pos = await enter_position_with_reposting_limit(
1832
+ info=info,
1833
+ exchange=exchange,
1834
+ account_address=account_address,
1835
+ coin=coin,
1836
+ is_buy=is_buy,
1837
+ size=size,
1838
+ retries=entry_retries,
1839
+ repost_interval=entry_repost_interval,
1840
+ entry_tif=entry_tif,
1841
+ market_fallback=market_fallback,
1842
+ market_slippage=market_slippage,
1843
+ metrics_start_time_ms=metrics_start_time_ms,
1844
+ )
1845
+
1846
+ try:
1847
+ current_size = float(pos["szi"])
1848
+ entry_px = float(pos["entryPx"])
1849
+ except (KeyError, TypeError, ValueError) as exc:
1850
+ raise RuntimeError(f"Could not parse opened position: {pos}. Error: {exc}") from exc
1851
+
1852
+ side = "long" if current_size > 0.0 else "short"
1853
+ pos_abs = abs(current_size)
1854
+ print(f"[OPEN] {coin} {side.upper()} size={pos_abs:.8f}, signed={current_size:.8f}, entry={entry_px:.8f}")
1855
+
1856
+ stop_oid, tp_orders, tp_oids, _ = await rebuild_bracket_orders(
1857
+ info=info,
1858
+ exchange=exchange,
1859
+ account_address=account_address,
1860
+ coin=coin,
1861
+ side=side,
1862
+ position_size_abs=pos_abs,
1863
+ entry_px=entry_px,
1864
+ take_profit_pct=take_profit_pct,
1865
+ stop_loss_pct=stop_loss_pct,
1866
+ stop_loss_trigger_px=stop_loss_trigger_px,
1867
+ take_profit_levels=take_profit_levels,
1868
+ tp_tif=tp_tif,
1869
+ market_slippage=market_slippage,
1870
+ cancel_existing_reduce_only=cancel_existing_tpsl,
1871
+ hide_orders=hide_orders,
1872
+ use_trailing_tp=use_trailing_tp,
1873
+ trailing_tp_profit_pct=trailing_tp_profit_pct,
1874
+ )
1875
+
1876
+ await monitor_bracket_position(
1877
+ info=info,
1878
+ exchange=exchange,
1879
+ account_address=account_address,
1880
+ coin=coin,
1881
+ side=side,
1882
+ entry_px=entry_px,
1883
+ take_profit_pct=take_profit_pct,
1884
+ tp_orders=tp_orders,
1885
+ tp_oids=tp_oids,
1886
+ stop_oid=stop_oid,
1887
+ managed_signed_size=current_size,
1888
+ poll_interval=poll_interval,
1889
+ reversal_pct=tp_reversal_pct,
1890
+ market_slippage=market_slippage,
1891
+ stop_loss_pct=stop_loss_pct,
1892
+ stop_loss_trigger_px=stop_loss_trigger_px,
1893
+ take_profit_levels=take_profit_levels,
1894
+ tp_tif=tp_tif,
1895
+ tp_reversal_limit_exit=tp_reversal_limit_exit,
1896
+ tp_reversal_stop_buffer_pct=tp_reversal_stop_buffer_pct,
1897
+ hide_orders=hide_orders,
1898
+ use_trailing_tp=use_trailing_tp,
1899
+ trailing_tp_trigger_level=trailing_tp_trigger_level,
1900
+ trailing_tp_profit_pct=trailing_tp_profit_pct,
1901
+ metrics_start_time_ms=metrics_start_time_ms,
1902
+ auto_sar_stop_interval=auto_sar_stop_interval,
1903
+ auto_sar_stop_periods=auto_sar_stop_periods,
1904
+ auto_sar_acceleration=auto_sar_acceleration,
1905
+ auto_sar_maximum=auto_sar_maximum,
1906
+ auto_use_last_closed_candle=auto_use_last_closed_candle,
1907
+ auto_use_websocket_candles=auto_use_websocket_candles,
1908
+ )
1909
+ finally:
1910
+ if owns_clients:
1911
+ await close_clients(info, exchange)