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.
- hypertrader-0.1.0.dist-info/METADATA +416 -0
- hypertrader-0.1.0.dist-info/RECORD +16 -0
- hypertrader-0.1.0.dist-info/WHEEL +5 -0
- hypertrader-0.1.0.dist-info/entry_points.txt +2 -0
- hypertrader-0.1.0.dist-info/top_level.txt +3 -0
- hypertrader.py +741 -0
- modes/auto_trader.py +2141 -0
- modes/market_maker.py +397 -0
- modes/position_management.py +1911 -0
- modes/position_watcher.py +411 -0
- modes/trailing_stop.py +277 -0
- utils/constants.py +31 -0
- utils/helpers.py +988 -0
- utils/style.py +173 -0
- utils/timer.py +14 -0
- utils/worker.py +233 -0
|
@@ -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)
|