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
modes/auto_trader.py
ADDED
|
@@ -0,0 +1,2141 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import decimal
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import numpy as np
|
|
9
|
+
import talib
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Optional, List, Dict, Any, Tuple
|
|
12
|
+
|
|
13
|
+
from hyperliquid.exchange import Exchange
|
|
14
|
+
from hyperliquid.info import Info
|
|
15
|
+
|
|
16
|
+
from modes.position_management import run_bracket_entry
|
|
17
|
+
from utils.constants import AUTO_TRADES_LOG_FILE, INTERVAL_TO_MS, cp
|
|
18
|
+
from utils.helpers import parse_interval_list, init_clients, _try_float, parse_fractional_pct, \
|
|
19
|
+
extract_account_balance_from_user_state, round_size_for_hyperliquid, get_user_state_with_retry, get_all_mids, \
|
|
20
|
+
fetch_recent_candles, compute_position_unrealized_pnl, close_clients, compute_default_stop_loss_pct, \
|
|
21
|
+
extract_closed_pnl_from_fill
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
_AUTO_TRADES_LOGGER: Optional[logging.Logger] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_auto_trades_logger() -> logging.Logger:
|
|
28
|
+
"""Return the dedicated auto-trade completion logger."""
|
|
29
|
+
global _AUTO_TRADES_LOGGER
|
|
30
|
+
|
|
31
|
+
if _AUTO_TRADES_LOGGER is not None:
|
|
32
|
+
return _AUTO_TRADES_LOGGER
|
|
33
|
+
|
|
34
|
+
resolved_path = os.path.abspath(AUTO_TRADES_LOG_FILE)
|
|
35
|
+
os.makedirs(os.path.dirname(resolved_path), exist_ok=True)
|
|
36
|
+
|
|
37
|
+
auto_logger = logging.getLogger("hypertrader.auto_trades")
|
|
38
|
+
auto_logger.setLevel(logging.INFO)
|
|
39
|
+
auto_logger.propagate = False
|
|
40
|
+
|
|
41
|
+
if not any(
|
|
42
|
+
isinstance(handler, logging.FileHandler) and getattr(handler, "baseFilename", None) == resolved_path
|
|
43
|
+
for handler in auto_logger.handlers
|
|
44
|
+
):
|
|
45
|
+
file_handler = logging.FileHandler(resolved_path, encoding="utf-8")
|
|
46
|
+
file_handler.setLevel(logging.INFO)
|
|
47
|
+
file_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
|
48
|
+
auto_logger.addHandler(file_handler)
|
|
49
|
+
|
|
50
|
+
_AUTO_TRADES_LOGGER = auto_logger
|
|
51
|
+
return auto_logger
|
|
52
|
+
|
|
53
|
+
def require_talib_available() -> None:
|
|
54
|
+
"""Fail early with an actionable message when auto-mode dependencies are missing."""
|
|
55
|
+
if np is None or talib is None:
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
"The auto command requires numpy and TA-Lib. Install them with:\n"
|
|
58
|
+
" pip install numpy TA-Lib\n\n"
|
|
59
|
+
"On Debian/Ubuntu, if the wheel is unavailable, install the TA-Lib C library first, for example:\n"
|
|
60
|
+
" sudo apt-get update && sudo apt-get install -y build-essential ta-lib\n"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def get_top_perp_markets_by_volume(info: Info, limit: int) -> List[Tuple[str, float]]:
|
|
64
|
+
"""Return the top perp markets ranked by reported day notional volume."""
|
|
65
|
+
if limit <= 0:
|
|
66
|
+
raise RuntimeError("--top-markets must be > 0.")
|
|
67
|
+
|
|
68
|
+
meta_and_ctxs = await info.meta_and_asset_ctxs()
|
|
69
|
+
if not isinstance(meta_and_ctxs, (list, tuple)) or len(meta_and_ctxs) < 2:
|
|
70
|
+
raise RuntimeError(f"Unexpected metaAndAssetCtxs response shape: {type(meta_and_ctxs).__name__}")
|
|
71
|
+
|
|
72
|
+
meta = meta_and_ctxs[0]
|
|
73
|
+
asset_ctxs = meta_and_ctxs[1]
|
|
74
|
+
universe = meta.get("universe", []) if isinstance(meta, dict) else []
|
|
75
|
+
if not isinstance(universe, list) or not isinstance(asset_ctxs, list):
|
|
76
|
+
raise RuntimeError("metaAndAssetCtxs response did not include perp universe and asset contexts lists.")
|
|
77
|
+
|
|
78
|
+
ranked: List[Tuple[str, float]] = []
|
|
79
|
+
for asset_info, ctx in zip(universe, asset_ctxs):
|
|
80
|
+
if not isinstance(asset_info, dict) or not isinstance(ctx, dict):
|
|
81
|
+
continue
|
|
82
|
+
coin = str(asset_info.get("name") or ctx.get("coin") or "").upper()
|
|
83
|
+
if not coin:
|
|
84
|
+
continue
|
|
85
|
+
volume = _try_float(ctx.get("dayNtlVlm"))
|
|
86
|
+
if volume is None or volume <= 0.0:
|
|
87
|
+
continue
|
|
88
|
+
ranked.append((coin, volume))
|
|
89
|
+
|
|
90
|
+
ranked.sort(key=lambda item: item[1], reverse=True)
|
|
91
|
+
return ranked[:limit]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# TA-Lib auto trader
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class AutoIntervalSignal:
|
|
100
|
+
"""TA-Lib indicator snapshot for one interval."""
|
|
101
|
+
interval: str
|
|
102
|
+
closes: List[float]
|
|
103
|
+
close: float
|
|
104
|
+
macd: float
|
|
105
|
+
macd_signal: float
|
|
106
|
+
macd_hist: float
|
|
107
|
+
sar: float
|
|
108
|
+
adx: float
|
|
109
|
+
bb_upper: float
|
|
110
|
+
bb_middle: float
|
|
111
|
+
bb_lower: float
|
|
112
|
+
direction: str
|
|
113
|
+
reason: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class AutoTradeDecision:
|
|
118
|
+
"""Aggregated multi-timeframe trade decision."""
|
|
119
|
+
direction: Optional[str]
|
|
120
|
+
current_px: float
|
|
121
|
+
target_px: Optional[float]
|
|
122
|
+
take_profit_pct: Optional[float]
|
|
123
|
+
stop_loss_pct: Optional[float]
|
|
124
|
+
stop_loss_trigger_px: Optional[float]
|
|
125
|
+
long_votes: int
|
|
126
|
+
short_votes: int
|
|
127
|
+
required_votes: int
|
|
128
|
+
snapshots: List[AutoIntervalSignal]
|
|
129
|
+
reason: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class BollingerState:
|
|
134
|
+
"""Computed Bollinger confirmation state for one selected interval."""
|
|
135
|
+
interval: str
|
|
136
|
+
basis: float
|
|
137
|
+
upper: float
|
|
138
|
+
lower: float
|
|
139
|
+
percent_b: float
|
|
140
|
+
basis_slope: float
|
|
141
|
+
bandwidth: float
|
|
142
|
+
previous_bandwidth: float
|
|
143
|
+
bandwidth_expanding: bool
|
|
144
|
+
latest_close: float
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class CoinTradeSessionState:
|
|
149
|
+
coin: str
|
|
150
|
+
realized_pnl: float = 0.0
|
|
151
|
+
peak_pnl: float = 0.0
|
|
152
|
+
completed_cycles: int = 0
|
|
153
|
+
winning_cycles: int = 0
|
|
154
|
+
losing_cycles: int = 0
|
|
155
|
+
consecutive_wins: int = 0
|
|
156
|
+
consecutive_losses: int = 0
|
|
157
|
+
cooldown_until_ms: int = 0
|
|
158
|
+
cooldown_reason: str = ""
|
|
159
|
+
last_cycle_pnl: float = 0.0
|
|
160
|
+
last_cycle_started_ms: int = 0
|
|
161
|
+
last_cycle_closed_ms: int = 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class AutoRiskSessionState:
|
|
166
|
+
started_ms: int
|
|
167
|
+
realized_pnl: float = 0.0
|
|
168
|
+
peak_pnl: float = 0.0
|
|
169
|
+
completed_cycles: int = 0
|
|
170
|
+
stopped: bool = False
|
|
171
|
+
stop_reason: str = ""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class AutoScanLoopSnapshot:
|
|
176
|
+
"""Per-scan shared state reused across all market candidates."""
|
|
177
|
+
mids: Dict[str, float]
|
|
178
|
+
positions_by_coin: Dict[str, Dict[str, Any]]
|
|
179
|
+
account_balance: Optional[float]
|
|
180
|
+
realized_pnl: Optional[float]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class AutoScanCandidate:
|
|
185
|
+
"""Outcome of scanning one market during a shared scan pass."""
|
|
186
|
+
coin: str
|
|
187
|
+
decision: Optional["AutoTradeDecision"]
|
|
188
|
+
existing_position: Optional[Dict[str, Any]]
|
|
189
|
+
rejection_reason: Optional[str] = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def now_ms() -> int:
|
|
193
|
+
return int(time.time() * 1000)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_or_create_coin_session(
|
|
197
|
+
coin_sessions: Dict[str, CoinTradeSessionState],
|
|
198
|
+
coin: str,
|
|
199
|
+
) -> CoinTradeSessionState:
|
|
200
|
+
coin_key = str(coin).upper()
|
|
201
|
+
existing = coin_sessions.get(coin_key)
|
|
202
|
+
if existing is not None:
|
|
203
|
+
return existing
|
|
204
|
+
created = CoinTradeSessionState(coin=coin_key)
|
|
205
|
+
coin_sessions[coin_key] = created
|
|
206
|
+
return created
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _append_risk_event_log(path: str, payload: Dict[str, Any]) -> None:
|
|
210
|
+
if not path:
|
|
211
|
+
return
|
|
212
|
+
resolved_path = os.path.abspath(path)
|
|
213
|
+
os.makedirs(os.path.dirname(resolved_path) or ".", exist_ok=True)
|
|
214
|
+
with open(resolved_path, "a", encoding="utf-8") as handle:
|
|
215
|
+
handle.write(json.dumps(payload, sort_keys=True) + "\n")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def reset_coin_session_after_cooldown_if_needed(
|
|
219
|
+
coin_state: CoinTradeSessionState,
|
|
220
|
+
current_time_ms: int,
|
|
221
|
+
risk_session_log: str = "",
|
|
222
|
+
session_pnl: Optional[float] = None,
|
|
223
|
+
) -> bool:
|
|
224
|
+
if coin_state.cooldown_until_ms <= 0 or current_time_ms < coin_state.cooldown_until_ms:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
print(
|
|
228
|
+
f"[AUTO-RISK] {coin_state.coin} cooldown expired; resetting coin session state "
|
|
229
|
+
f'(previous_reason="{coin_state.cooldown_reason}")'
|
|
230
|
+
)
|
|
231
|
+
_append_risk_event_log(
|
|
232
|
+
risk_session_log,
|
|
233
|
+
{
|
|
234
|
+
"ts_ms": current_time_ms,
|
|
235
|
+
"event": "coin_cooldown_expired",
|
|
236
|
+
"coin": coin_state.coin,
|
|
237
|
+
"reason": coin_state.cooldown_reason,
|
|
238
|
+
"coin_pnl": coin_state.realized_pnl,
|
|
239
|
+
"session_pnl": session_pnl,
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
coin_state.realized_pnl = 0.0
|
|
243
|
+
coin_state.peak_pnl = 0.0
|
|
244
|
+
coin_state.completed_cycles = 0
|
|
245
|
+
coin_state.winning_cycles = 0
|
|
246
|
+
coin_state.losing_cycles = 0
|
|
247
|
+
coin_state.consecutive_wins = 0
|
|
248
|
+
coin_state.consecutive_losses = 0
|
|
249
|
+
coin_state.cooldown_until_ms = 0
|
|
250
|
+
coin_state.cooldown_reason = ""
|
|
251
|
+
coin_state.last_cycle_pnl = 0.0
|
|
252
|
+
coin_state.last_cycle_started_ms = 0
|
|
253
|
+
coin_state.last_cycle_closed_ms = 0
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def coin_is_blocked_by_risk(
|
|
258
|
+
coin_state: CoinTradeSessionState,
|
|
259
|
+
current_time_ms: int,
|
|
260
|
+
) -> Tuple[bool, str]:
|
|
261
|
+
if coin_state.cooldown_until_ms > current_time_ms:
|
|
262
|
+
remaining_seconds = (coin_state.cooldown_until_ms - current_time_ms) / 1000.0
|
|
263
|
+
return (
|
|
264
|
+
True,
|
|
265
|
+
f'cooldown active {remaining_seconds:.1f}s remaining reason="{coin_state.cooldown_reason}"',
|
|
266
|
+
)
|
|
267
|
+
return False, ""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def set_coin_cooldown(
|
|
271
|
+
coin_state: CoinTradeSessionState,
|
|
272
|
+
cooldown_seconds: float,
|
|
273
|
+
reason: str,
|
|
274
|
+
) -> None:
|
|
275
|
+
coin_state.cooldown_reason = reason
|
|
276
|
+
if cooldown_seconds > 0.0:
|
|
277
|
+
coin_state.cooldown_until_ms = now_ms() + int(cooldown_seconds * 1000)
|
|
278
|
+
else:
|
|
279
|
+
coin_state.cooldown_until_ms = now_ms()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def fetch_trade_cycle_net_pnl(
|
|
283
|
+
info: Info,
|
|
284
|
+
account_address: str,
|
|
285
|
+
coin: str,
|
|
286
|
+
start_time_ms: int,
|
|
287
|
+
end_time_ms: int,
|
|
288
|
+
) -> Tuple[float, List[Dict[str, Any]], str]:
|
|
289
|
+
request_start_ms = start_time_ms - 5000
|
|
290
|
+
request_end_ms = end_time_ms + 5000
|
|
291
|
+
fills: Any
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
try:
|
|
295
|
+
fills = await info.user_fills_by_time(
|
|
296
|
+
account_address,
|
|
297
|
+
request_start_ms,
|
|
298
|
+
request_end_ms,
|
|
299
|
+
aggregate_by_time=False,
|
|
300
|
+
)
|
|
301
|
+
except TypeError:
|
|
302
|
+
fills = await info.post(
|
|
303
|
+
"/info",
|
|
304
|
+
{
|
|
305
|
+
"type": "userFillsByTime",
|
|
306
|
+
"user": account_address,
|
|
307
|
+
"startTime": request_start_ms,
|
|
308
|
+
"endTime": request_end_ms,
|
|
309
|
+
"aggregateByTime": False,
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
return 0.0, [], f"pnl_unavailable: {exc}"
|
|
314
|
+
|
|
315
|
+
if not isinstance(fills, list):
|
|
316
|
+
return 0.0, [], f"pnl_unavailable: unexpected fills response type {type(fills).__name__}"
|
|
317
|
+
|
|
318
|
+
coin_upper = str(coin).upper()
|
|
319
|
+
filtered_fills: List[Dict[str, Any]] = []
|
|
320
|
+
net_pnl = 0.0
|
|
321
|
+
for fill in fills:
|
|
322
|
+
if not isinstance(fill, dict):
|
|
323
|
+
continue
|
|
324
|
+
if str(fill.get("coin", "")).upper() != coin_upper:
|
|
325
|
+
continue
|
|
326
|
+
fill_time_ms = int(_try_float(fill.get("time")) or _try_float(fill.get("timestamp")) or 0.0)
|
|
327
|
+
if fill_time_ms < request_start_ms or fill_time_ms > request_end_ms:
|
|
328
|
+
continue
|
|
329
|
+
closed_pnl = _try_float(fill.get("closedPnl"))
|
|
330
|
+
if closed_pnl is None:
|
|
331
|
+
closed_pnl = _try_float(fill.get("closedPnL"))
|
|
332
|
+
if closed_pnl is None:
|
|
333
|
+
closed_pnl = _try_float(fill.get("realizedPnl"))
|
|
334
|
+
if closed_pnl is None:
|
|
335
|
+
closed_pnl = _try_float(fill.get("realizedPnL"))
|
|
336
|
+
if closed_pnl is None:
|
|
337
|
+
closed_pnl = _try_float(fill.get("realized_pnl"))
|
|
338
|
+
fee = _try_float(fill.get("fee")) or 0.0
|
|
339
|
+
net_pnl += (closed_pnl or 0.0) - fee
|
|
340
|
+
filtered_fills.append(fill)
|
|
341
|
+
|
|
342
|
+
if not filtered_fills:
|
|
343
|
+
return 0.0, [], "pnl_unavailable: no matching fills in trade cycle window"
|
|
344
|
+
|
|
345
|
+
return net_pnl, filtered_fills, ""
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def update_risk_after_trade_cycle(
|
|
349
|
+
coin_state: CoinTradeSessionState,
|
|
350
|
+
session_state: AutoRiskSessionState,
|
|
351
|
+
cycle_pnl: float,
|
|
352
|
+
cycle_started_ms: int,
|
|
353
|
+
cycle_closed_ms: int,
|
|
354
|
+
max_coin_trades_per_session: int,
|
|
355
|
+
coin_session_cooldown_seconds: float,
|
|
356
|
+
coin_session_profit_target: float,
|
|
357
|
+
coin_session_min_profit_to_lock: float,
|
|
358
|
+
coin_session_giveback_pct: float,
|
|
359
|
+
cooldown_after_loss_following_wins: int,
|
|
360
|
+
session_profit_target: float,
|
|
361
|
+
session_max_loss: float,
|
|
362
|
+
session_giveback_pct: float,
|
|
363
|
+
) -> Tuple[Optional[str], Optional[str]]:
|
|
364
|
+
previous_consecutive_wins = coin_state.consecutive_wins
|
|
365
|
+
|
|
366
|
+
coin_state.realized_pnl += cycle_pnl
|
|
367
|
+
coin_state.peak_pnl = max(coin_state.peak_pnl, coin_state.realized_pnl)
|
|
368
|
+
coin_state.completed_cycles += 1
|
|
369
|
+
coin_state.last_cycle_pnl = cycle_pnl
|
|
370
|
+
coin_state.last_cycle_started_ms = cycle_started_ms
|
|
371
|
+
coin_state.last_cycle_closed_ms = cycle_closed_ms
|
|
372
|
+
|
|
373
|
+
if cycle_pnl > 0.0:
|
|
374
|
+
coin_state.winning_cycles += 1
|
|
375
|
+
coin_state.consecutive_wins += 1
|
|
376
|
+
coin_state.consecutive_losses = 0
|
|
377
|
+
elif cycle_pnl < 0.0:
|
|
378
|
+
coin_state.losing_cycles += 1
|
|
379
|
+
coin_state.consecutive_losses += 1
|
|
380
|
+
coin_state.consecutive_wins = 0
|
|
381
|
+
else:
|
|
382
|
+
coin_state.consecutive_wins = 0
|
|
383
|
+
coin_state.consecutive_losses = 0
|
|
384
|
+
|
|
385
|
+
session_state.realized_pnl += cycle_pnl
|
|
386
|
+
session_state.peak_pnl = max(session_state.peak_pnl, session_state.realized_pnl)
|
|
387
|
+
session_state.completed_cycles += 1
|
|
388
|
+
|
|
389
|
+
coin_reason: Optional[str] = None
|
|
390
|
+
if 0 < max_coin_trades_per_session <= coin_state.completed_cycles:
|
|
391
|
+
coin_reason = (
|
|
392
|
+
"max_coin_trades_per_session reached: "
|
|
393
|
+
f"completed_cycles={coin_state.completed_cycles} limit={max_coin_trades_per_session}"
|
|
394
|
+
)
|
|
395
|
+
elif 0.0 < coin_session_profit_target <= coin_state.realized_pnl:
|
|
396
|
+
coin_reason = (
|
|
397
|
+
"coin profit target reached: "
|
|
398
|
+
f"pnl={coin_state.realized_pnl:.6f} target={coin_session_profit_target:.6f}"
|
|
399
|
+
)
|
|
400
|
+
elif (
|
|
401
|
+
0 < cooldown_after_loss_following_wins <= previous_consecutive_wins
|
|
402
|
+
and cycle_pnl < 0.0
|
|
403
|
+
):
|
|
404
|
+
coin_reason = (
|
|
405
|
+
"loss after win streak: "
|
|
406
|
+
f"previous_wins={previous_consecutive_wins} loss={cycle_pnl:.6f}"
|
|
407
|
+
)
|
|
408
|
+
elif (
|
|
409
|
+
coin_session_giveback_pct > 0.0
|
|
410
|
+
and coin_state.peak_pnl > 0.0
|
|
411
|
+
and coin_state.peak_pnl >= coin_session_min_profit_to_lock
|
|
412
|
+
):
|
|
413
|
+
threshold = coin_state.peak_pnl * (1.0 - coin_session_giveback_pct)
|
|
414
|
+
if coin_state.realized_pnl <= threshold:
|
|
415
|
+
coin_reason = (
|
|
416
|
+
"coin giveback stop: "
|
|
417
|
+
f"pnl={coin_state.realized_pnl:.6f} peak={coin_state.peak_pnl:.6f} "
|
|
418
|
+
f"giveback_pct={coin_session_giveback_pct:.6f} threshold={threshold:.6f}"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
if coin_reason is not None:
|
|
422
|
+
set_coin_cooldown(
|
|
423
|
+
coin_state=coin_state,
|
|
424
|
+
cooldown_seconds=coin_session_cooldown_seconds,
|
|
425
|
+
reason=coin_reason,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
session_reason: Optional[str] = None
|
|
429
|
+
if session_max_loss > 0.0 and session_state.realized_pnl <= -abs(session_max_loss):
|
|
430
|
+
session_reason = (
|
|
431
|
+
"session max loss reached "
|
|
432
|
+
f"pnl={session_state.realized_pnl:.6f} limit=-{abs(session_max_loss):.6f}"
|
|
433
|
+
)
|
|
434
|
+
elif 0.0 < session_profit_target <= session_state.realized_pnl:
|
|
435
|
+
session_reason = (
|
|
436
|
+
"session profit target reached "
|
|
437
|
+
f"pnl={session_state.realized_pnl:.6f} target={session_profit_target:.6f}"
|
|
438
|
+
)
|
|
439
|
+
elif (
|
|
440
|
+
session_giveback_pct > 0.0
|
|
441
|
+
and session_state.peak_pnl > 0.0
|
|
442
|
+
):
|
|
443
|
+
threshold = session_state.peak_pnl * (1.0 - session_giveback_pct)
|
|
444
|
+
if session_state.realized_pnl <= threshold:
|
|
445
|
+
session_reason = (
|
|
446
|
+
"session giveback stop "
|
|
447
|
+
f"pnl={session_state.realized_pnl:.6f} peak={session_state.peak_pnl:.6f} "
|
|
448
|
+
f"giveback_pct={session_giveback_pct:.6f} threshold={threshold:.6f}"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
if session_reason is not None:
|
|
452
|
+
session_state.stopped = True
|
|
453
|
+
session_state.stop_reason = session_reason
|
|
454
|
+
|
|
455
|
+
return coin_reason, session_reason
|
|
456
|
+
|
|
457
|
+
def _normalize_state_key(key: Any) -> str:
|
|
458
|
+
"""Normalize account-state keys so minor API casing/name shifts still match."""
|
|
459
|
+
return "".join(ch for ch in str(key).lower() if ch.isalnum())
|
|
460
|
+
|
|
461
|
+
def _decimal_from_margin_value(value: Any, *, field_name: str) -> decimal.Decimal:
|
|
462
|
+
"""Convert Hyperliquid numeric payload fields into Decimal safely."""
|
|
463
|
+
if value is None:
|
|
464
|
+
raise RuntimeError(f"{field_name} is missing.")
|
|
465
|
+
|
|
466
|
+
if isinstance(value, decimal.Decimal):
|
|
467
|
+
return value
|
|
468
|
+
|
|
469
|
+
raw = str(value).strip()
|
|
470
|
+
if raw.endswith("%"):
|
|
471
|
+
raw = raw[:-1].strip()
|
|
472
|
+
if not raw or raw.lower() in {"nan", "none", "null"}:
|
|
473
|
+
raise RuntimeError(f"{field_name} is not a usable number: {value!r}")
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
return decimal.Decimal(raw)
|
|
477
|
+
except decimal.InvalidOperation as exc:
|
|
478
|
+
raise RuntimeError(f"{field_name} is not a valid decimal: {value!r}") from exc
|
|
479
|
+
|
|
480
|
+
def _find_first_numeric_field(
|
|
481
|
+
payload: Any,
|
|
482
|
+
candidate_keys: Tuple[str, ...],
|
|
483
|
+
path: Tuple[str, ...] = (),
|
|
484
|
+
) -> Tuple[Optional[float], Optional[str]]:
|
|
485
|
+
"""Depth-first search for the first matching numeric field in nested account state."""
|
|
486
|
+
if isinstance(payload, dict):
|
|
487
|
+
normalized_map = {_normalize_state_key(key): key for key in payload.keys()}
|
|
488
|
+
for candidate_key in candidate_keys:
|
|
489
|
+
actual_key = normalized_map.get(_normalize_state_key(candidate_key))
|
|
490
|
+
if actual_key is None:
|
|
491
|
+
continue
|
|
492
|
+
value = _try_float(payload.get(actual_key))
|
|
493
|
+
if value is not None and value >= 0.0:
|
|
494
|
+
return value, ".".join(path + (str(actual_key),))
|
|
495
|
+
|
|
496
|
+
for key, value in payload.items():
|
|
497
|
+
nested_value, nested_path = _find_first_numeric_field(value, candidate_keys, path + (str(key),))
|
|
498
|
+
if nested_value is not None and nested_path is not None:
|
|
499
|
+
return nested_value, nested_path
|
|
500
|
+
return None, None
|
|
501
|
+
|
|
502
|
+
if isinstance(payload, list):
|
|
503
|
+
for idx, value in enumerate(payload):
|
|
504
|
+
nested_value, nested_path = _find_first_numeric_field(value, candidate_keys, path + (f"[{idx}]",))
|
|
505
|
+
if nested_value is not None and nested_path is not None:
|
|
506
|
+
return nested_value, nested_path
|
|
507
|
+
return None, None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def extract_available_collateral_from_user_state(user_state: Dict[str, Any]) -> Tuple[Optional[float], str]:
|
|
511
|
+
"""Extract available collateral with the same priority order as testpct.py."""
|
|
512
|
+
with decimal.localcontext() as ctx:
|
|
513
|
+
ctx.prec = 28
|
|
514
|
+
|
|
515
|
+
withdrawable = user_state.get("withdrawable")
|
|
516
|
+
if withdrawable is not None:
|
|
517
|
+
try:
|
|
518
|
+
available = _decimal_from_margin_value(withdrawable, field_name="withdrawable")
|
|
519
|
+
except RuntimeError:
|
|
520
|
+
available = None
|
|
521
|
+
if available is not None and available >= 0:
|
|
522
|
+
return float(available), "withdrawable"
|
|
523
|
+
|
|
524
|
+
summary = user_state.get("crossMarginSummary")
|
|
525
|
+
summary_name = "crossMarginSummary"
|
|
526
|
+
if not isinstance(summary, dict):
|
|
527
|
+
summary = user_state.get("marginSummary")
|
|
528
|
+
summary_name = "marginSummary"
|
|
529
|
+
if isinstance(summary, dict):
|
|
530
|
+
account_value_raw = summary.get("accountValue")
|
|
531
|
+
total_margin_used_raw = summary.get("totalMarginUsed")
|
|
532
|
+
if account_value_raw is not None and total_margin_used_raw is not None:
|
|
533
|
+
try:
|
|
534
|
+
account_value = _decimal_from_margin_value(
|
|
535
|
+
account_value_raw,
|
|
536
|
+
field_name=f"{summary_name}.accountValue",
|
|
537
|
+
)
|
|
538
|
+
total_margin_used = _decimal_from_margin_value(
|
|
539
|
+
total_margin_used_raw,
|
|
540
|
+
field_name=f"{summary_name}.totalMarginUsed",
|
|
541
|
+
)
|
|
542
|
+
available = max(decimal.Decimal("0"), account_value - total_margin_used)
|
|
543
|
+
return float(available), f"{summary_name}.accountValue-totalMarginUsed"
|
|
544
|
+
except RuntimeError:
|
|
545
|
+
pass
|
|
546
|
+
|
|
547
|
+
candidate_paths = (
|
|
548
|
+
("availableToWithdraw",),
|
|
549
|
+
("availableBalance",),
|
|
550
|
+
("availableCollateral",),
|
|
551
|
+
("freeCollateral",),
|
|
552
|
+
("usableBalance",),
|
|
553
|
+
("totalWithdrawable",),
|
|
554
|
+
("crossMarginSummary", "withdrawable"),
|
|
555
|
+
("crossMarginSummary", "availableToWithdraw"),
|
|
556
|
+
("crossMarginSummary", "availableBalance"),
|
|
557
|
+
("crossMarginSummary", "availableCollateral"),
|
|
558
|
+
("crossMarginSummary", "freeCollateral"),
|
|
559
|
+
("marginSummary", "withdrawable"),
|
|
560
|
+
("marginSummary", "availableToWithdraw"),
|
|
561
|
+
("marginSummary", "availableBalance"),
|
|
562
|
+
("marginSummary", "availableCollateral"),
|
|
563
|
+
("marginSummary", "freeCollateral"),
|
|
564
|
+
("marginSummary", "accountValue"),
|
|
565
|
+
("crossMarginSummary", "accountValue"),
|
|
566
|
+
("portfolio", "accountValue"),
|
|
567
|
+
("accountValue",),
|
|
568
|
+
("totalAccountValue",),
|
|
569
|
+
("balance",),
|
|
570
|
+
)
|
|
571
|
+
for path in candidate_paths:
|
|
572
|
+
current: Any = user_state
|
|
573
|
+
valid = True
|
|
574
|
+
for key in path:
|
|
575
|
+
if not isinstance(current, dict):
|
|
576
|
+
valid = False
|
|
577
|
+
break
|
|
578
|
+
current = current.get(key)
|
|
579
|
+
if not valid:
|
|
580
|
+
continue
|
|
581
|
+
value = _try_float(current)
|
|
582
|
+
if value is not None and value >= 0.0:
|
|
583
|
+
return value, ".".join(path)
|
|
584
|
+
|
|
585
|
+
recursive_available, recursive_available_path = _find_first_numeric_field(
|
|
586
|
+
user_state,
|
|
587
|
+
(
|
|
588
|
+
"withdrawable",
|
|
589
|
+
"availableToWithdraw",
|
|
590
|
+
"availableBalance",
|
|
591
|
+
"availableCollateral",
|
|
592
|
+
"freeCollateral",
|
|
593
|
+
"usableBalance",
|
|
594
|
+
"totalWithdrawable",
|
|
595
|
+
),
|
|
596
|
+
)
|
|
597
|
+
if recursive_available is not None and recursive_available_path is not None:
|
|
598
|
+
return recursive_available, recursive_available_path
|
|
599
|
+
|
|
600
|
+
recursive_balance, recursive_balance_path = _find_first_numeric_field(
|
|
601
|
+
user_state,
|
|
602
|
+
("accountValue", "totalAccountValue", "balance"),
|
|
603
|
+
)
|
|
604
|
+
if recursive_balance is not None and recursive_balance_path is not None:
|
|
605
|
+
return recursive_balance, recursive_balance_path
|
|
606
|
+
|
|
607
|
+
fallback_balance = extract_account_balance_from_user_state(user_state)
|
|
608
|
+
if fallback_balance is not None and fallback_balance >= 0.0:
|
|
609
|
+
return fallback_balance, "account_balance_fallback"
|
|
610
|
+
return None, "unknown"
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def extract_open_positions_by_coin(user_state: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
|
614
|
+
"""Build a coin->position map from one user_state snapshot."""
|
|
615
|
+
positions_by_coin: Dict[str, Dict[str, Any]] = {}
|
|
616
|
+
asset_positions = user_state.get("assetPositions", [])
|
|
617
|
+
if not isinstance(asset_positions, list):
|
|
618
|
+
return positions_by_coin
|
|
619
|
+
|
|
620
|
+
for asset_pos in asset_positions:
|
|
621
|
+
if not isinstance(asset_pos, dict):
|
|
622
|
+
continue
|
|
623
|
+
position = asset_pos.get("position", {})
|
|
624
|
+
if not isinstance(position, dict):
|
|
625
|
+
continue
|
|
626
|
+
coin = str(position.get("coin", "")).upper()
|
|
627
|
+
if not coin:
|
|
628
|
+
continue
|
|
629
|
+
try:
|
|
630
|
+
if float(position.get("szi", "0")) == 0.0:
|
|
631
|
+
continue
|
|
632
|
+
except (TypeError, ValueError):
|
|
633
|
+
continue
|
|
634
|
+
positions_by_coin[coin] = position
|
|
635
|
+
return positions_by_coin
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def extract_realized_pnl_by_coin_since(fills: Any) -> Dict[str, float]:
|
|
639
|
+
"""Aggregate closed PnL by coin from one fills response."""
|
|
640
|
+
realized_by_coin: Dict[str, float] = {}
|
|
641
|
+
if not isinstance(fills, list):
|
|
642
|
+
return realized_by_coin
|
|
643
|
+
|
|
644
|
+
for fill in fills:
|
|
645
|
+
if not isinstance(fill, dict):
|
|
646
|
+
continue
|
|
647
|
+
coin = str(fill.get("coin", "")).upper()
|
|
648
|
+
if not coin:
|
|
649
|
+
continue
|
|
650
|
+
realized_by_coin[coin] = realized_by_coin.get(coin, 0.0) + extract_closed_pnl_from_fill(fill)
|
|
651
|
+
return realized_by_coin
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
async def build_auto_scan_loop_snapshot(
|
|
655
|
+
info: Info,
|
|
656
|
+
account_address: str,
|
|
657
|
+
metrics_start_time_ms: int,
|
|
658
|
+
) -> AutoScanLoopSnapshot:
|
|
659
|
+
"""Fetch shared scan-loop state once to avoid per-market REST duplication."""
|
|
660
|
+
user_state = await get_user_state_with_retry(
|
|
661
|
+
info,
|
|
662
|
+
account_address,
|
|
663
|
+
context_label="auto_scan_loop_snapshot",
|
|
664
|
+
)
|
|
665
|
+
positions_by_coin = extract_open_positions_by_coin(user_state)
|
|
666
|
+
account_balance = extract_account_balance_from_user_state(user_state)
|
|
667
|
+
|
|
668
|
+
mids_task = asyncio.create_task(get_all_mids(info))
|
|
669
|
+
fills_task = asyncio.create_task(info.user_fills_by_time(account_address, metrics_start_time_ms))
|
|
670
|
+
|
|
671
|
+
mids = await mids_task
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
fills = await fills_task
|
|
675
|
+
except Exception as exc:
|
|
676
|
+
cp.warning(f"[AUTO-WARN] Failed to fetch user fills for session metrics: {exc}")
|
|
677
|
+
fills = []
|
|
678
|
+
realized_by_coin = extract_realized_pnl_by_coin_since(fills)
|
|
679
|
+
|
|
680
|
+
return AutoScanLoopSnapshot(
|
|
681
|
+
mids=mids,
|
|
682
|
+
positions_by_coin=positions_by_coin,
|
|
683
|
+
account_balance=account_balance,
|
|
684
|
+
realized_pnl=sum(realized_by_coin.values()) if realized_by_coin or isinstance(fills, list) else None,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def format_auto_scan_metrics(snapshot: AutoScanLoopSnapshot) -> str:
|
|
689
|
+
"""Format the shared auto-scan account metrics display."""
|
|
690
|
+
realized = f"{snapshot.realized_pnl:.8f}" if snapshot.realized_pnl is not None else "N/A"
|
|
691
|
+
balance = f"{snapshot.account_balance:.8f}" if snapshot.account_balance is not None else "N/A"
|
|
692
|
+
return f"rpnl={realized} balance={balance}"
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _active_asset_side_index(side: str) -> int:
|
|
696
|
+
normalized = str(side).strip().lower()
|
|
697
|
+
if normalized in {"long", "buy", "b", "bid"}:
|
|
698
|
+
return 0
|
|
699
|
+
if normalized in {"short", "sell", "s", "ask"}:
|
|
700
|
+
return 1
|
|
701
|
+
raise RuntimeError(f"Unsupported side for activeAssetData sizing: {side!r}")
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def normalize_bollinger_side(side: str) -> str:
|
|
705
|
+
"""Map project side aliases onto long/short confirmation rules."""
|
|
706
|
+
normalized = str(side).strip().lower()
|
|
707
|
+
if normalized in {"long", "buy", "b", "bid"}:
|
|
708
|
+
return "long"
|
|
709
|
+
if normalized in {"short", "sell", "s", "a", "ask"}:
|
|
710
|
+
return "short"
|
|
711
|
+
raise RuntimeError(f"Unsupported side for Bollinger confirmation: {side!r}")
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def interval_to_seconds(interval: str) -> int:
|
|
715
|
+
"""Convert supported interval strings to seconds for dynamic ordering."""
|
|
716
|
+
normalized = str(interval).strip()
|
|
717
|
+
if normalized in INTERVAL_TO_MS:
|
|
718
|
+
return int(INTERVAL_TO_MS[normalized] / 1000)
|
|
719
|
+
|
|
720
|
+
lower = normalized.lower()
|
|
721
|
+
if lower in INTERVAL_TO_MS:
|
|
722
|
+
return int(INTERVAL_TO_MS[lower] / 1000)
|
|
723
|
+
if lower.endswith("m"):
|
|
724
|
+
return int(lower[:-1]) * 60
|
|
725
|
+
if lower.endswith("h"):
|
|
726
|
+
return int(lower[:-1]) * 60 * 60
|
|
727
|
+
if lower.endswith("d"):
|
|
728
|
+
return int(lower[:-1]) * 24 * 60 * 60
|
|
729
|
+
if lower.endswith("w"):
|
|
730
|
+
return int(lower[:-1]) * 7 * 24 * 60 * 60
|
|
731
|
+
raise ValueError(f"Unsupported interval: {interval}")
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def select_bollinger_intervals(active_intervals: List[str], scalp: bool) -> Dict[str, str]:
|
|
735
|
+
"""Select dynamic Bollinger confirmation intervals from the current auto run."""
|
|
736
|
+
if not active_intervals:
|
|
737
|
+
raise RuntimeError("No active intervals were configured for Bollinger confirmation.")
|
|
738
|
+
|
|
739
|
+
ordered = sorted(active_intervals, key=interval_to_seconds)
|
|
740
|
+
if scalp:
|
|
741
|
+
return {"entry": ordered[0]}
|
|
742
|
+
|
|
743
|
+
if len(ordered) == 1:
|
|
744
|
+
return {"entry": ordered[0], "regime": ordered[0]}
|
|
745
|
+
if len(ordered) == 2:
|
|
746
|
+
return {"entry": ordered[0], "regime": ordered[1]}
|
|
747
|
+
if len(ordered) == 3:
|
|
748
|
+
return {"entry": ordered[0], "setup": ordered[1], "regime": ordered[2]}
|
|
749
|
+
|
|
750
|
+
entry_index = 1 if ordered[0] == "1m" else 0
|
|
751
|
+
return {
|
|
752
|
+
"entry": ordered[entry_index],
|
|
753
|
+
"setup": ordered[len(ordered) // 2],
|
|
754
|
+
"regime": ordered[-1],
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def compute_bollinger_state(
|
|
759
|
+
interval: str,
|
|
760
|
+
closes: List[float],
|
|
761
|
+
period: int = 20,
|
|
762
|
+
stddev_multiplier: float = 2.0,
|
|
763
|
+
) -> BollingerState:
|
|
764
|
+
"""Compute current and previous Bollinger state for one interval."""
|
|
765
|
+
require_talib_available()
|
|
766
|
+
if period <= 1:
|
|
767
|
+
raise RuntimeError(f"Bollinger period must be > 1, got {period}.")
|
|
768
|
+
if len(closes) < period + 1:
|
|
769
|
+
raise RuntimeError(
|
|
770
|
+
f"{interval} has insufficient close data for Bollinger confirmation: "
|
|
771
|
+
f"got {len(closes)}, need at least {period + 1}."
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
close_arr = np.asarray(closes, dtype=float) # type: ignore[union-attr]
|
|
775
|
+
upper_arr, basis_arr, lower_arr = talib.BBANDS( # type: ignore[union-attr]
|
|
776
|
+
close_arr,
|
|
777
|
+
timeperiod=period,
|
|
778
|
+
nbdevup=stddev_multiplier,
|
|
779
|
+
nbdevdn=stddev_multiplier,
|
|
780
|
+
matype=0,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
current_idx = last_finite_index(close_arr, upper_arr, basis_arr, lower_arr)
|
|
784
|
+
previous_idx: Optional[int] = None
|
|
785
|
+
for idx in range(current_idx - 1, -1, -1):
|
|
786
|
+
values = (close_arr[idx], upper_arr[idx], basis_arr[idx], lower_arr[idx])
|
|
787
|
+
if all(math.isfinite(float(value)) for value in values):
|
|
788
|
+
previous_idx = idx
|
|
789
|
+
break
|
|
790
|
+
if previous_idx is None:
|
|
791
|
+
raise RuntimeError(f"{interval} does not have enough completed Bollinger windows for slope confirmation.")
|
|
792
|
+
|
|
793
|
+
latest_close = float(close_arr[current_idx])
|
|
794
|
+
upper = float(upper_arr[current_idx])
|
|
795
|
+
basis = float(basis_arr[current_idx])
|
|
796
|
+
lower = float(lower_arr[current_idx])
|
|
797
|
+
previous_upper = float(upper_arr[previous_idx])
|
|
798
|
+
previous_basis = float(basis_arr[previous_idx])
|
|
799
|
+
previous_lower = float(lower_arr[previous_idx])
|
|
800
|
+
|
|
801
|
+
band_span = upper - lower
|
|
802
|
+
if abs(band_span) <= 0.0:
|
|
803
|
+
raise RuntimeError(f"{interval} Bollinger band span is zero; skipping confirmation safely.")
|
|
804
|
+
if basis == 0.0:
|
|
805
|
+
raise RuntimeError(f"{interval} Bollinger basis is zero; skipping confirmation safely.")
|
|
806
|
+
previous_band_span = previous_upper - previous_lower
|
|
807
|
+
if previous_basis == 0.0:
|
|
808
|
+
raise RuntimeError(f"{interval} previous Bollinger basis is zero; skipping confirmation safely.")
|
|
809
|
+
|
|
810
|
+
percent_b = (latest_close - lower) / band_span
|
|
811
|
+
basis_slope = basis - previous_basis
|
|
812
|
+
bandwidth = band_span / basis
|
|
813
|
+
previous_bandwidth = previous_band_span / previous_basis
|
|
814
|
+
|
|
815
|
+
return BollingerState(
|
|
816
|
+
interval=interval,
|
|
817
|
+
basis=basis,
|
|
818
|
+
upper=upper,
|
|
819
|
+
lower=lower,
|
|
820
|
+
percent_b=percent_b,
|
|
821
|
+
basis_slope=basis_slope,
|
|
822
|
+
bandwidth=bandwidth,
|
|
823
|
+
previous_bandwidth=previous_bandwidth,
|
|
824
|
+
bandwidth_expanding=bandwidth > previous_bandwidth,
|
|
825
|
+
latest_close=latest_close,
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _scalp_quality_label(side: str, percent_b: float) -> str:
|
|
830
|
+
"""Return scalp-mode quality label for logs."""
|
|
831
|
+
if side == "long":
|
|
832
|
+
if percent_b <= 0.25:
|
|
833
|
+
return "ideal"
|
|
834
|
+
if percent_b <= 0.35:
|
|
835
|
+
return "good"
|
|
836
|
+
if percent_b <= 0.50:
|
|
837
|
+
return "valid"
|
|
838
|
+
return "extended"
|
|
839
|
+
if percent_b >= 0.75:
|
|
840
|
+
return "ideal"
|
|
841
|
+
if percent_b >= 0.65:
|
|
842
|
+
return "good"
|
|
843
|
+
if percent_b >= 0.50:
|
|
844
|
+
return "valid"
|
|
845
|
+
return "extended"
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def confirm_signal_with_bollinger(
|
|
849
|
+
side: str,
|
|
850
|
+
interval_to_closes: Dict[str, List[float]],
|
|
851
|
+
active_intervals: List[str],
|
|
852
|
+
scalp: bool,
|
|
853
|
+
period: int = 20,
|
|
854
|
+
stddev_multiplier: float = 2.0,
|
|
855
|
+
) -> Tuple[bool, str, Dict[str, BollingerState]]:
|
|
856
|
+
"""Confirm an already-generated directional auto signal with Bollinger context."""
|
|
857
|
+
try:
|
|
858
|
+
normalized_side = normalize_bollinger_side(side)
|
|
859
|
+
selected_intervals = select_bollinger_intervals(active_intervals, scalp)
|
|
860
|
+
except Exception as exc:
|
|
861
|
+
return False, str(exc), {}
|
|
862
|
+
|
|
863
|
+
states: Dict[str, BollingerState] = {}
|
|
864
|
+
for role, interval in selected_intervals.items():
|
|
865
|
+
closes = interval_to_closes.get(interval)
|
|
866
|
+
if not closes:
|
|
867
|
+
return False, f"{role} interval {interval} has no usable close data for Bollinger confirmation.", states
|
|
868
|
+
try:
|
|
869
|
+
states[role] = compute_bollinger_state(
|
|
870
|
+
interval=interval,
|
|
871
|
+
closes=closes,
|
|
872
|
+
period=period,
|
|
873
|
+
stddev_multiplier=stddev_multiplier,
|
|
874
|
+
)
|
|
875
|
+
except Exception as exc:
|
|
876
|
+
return False, f"{role} interval {interval} Bollinger confirmation failed: {exc}", states
|
|
877
|
+
|
|
878
|
+
if scalp:
|
|
879
|
+
entry_state = states["entry"]
|
|
880
|
+
quality = _scalp_quality_label(normalized_side, entry_state.percent_b)
|
|
881
|
+
if normalized_side == "long":
|
|
882
|
+
allowed = entry_state.percent_b <= 0.50
|
|
883
|
+
if not allowed:
|
|
884
|
+
return False, (
|
|
885
|
+
f"mode=scalp side=long entry_tf={entry_state.interval} %B={entry_state.percent_b:.3f} "
|
|
886
|
+
f"result=REJECT reason=\"long rejected: entry interval above middle band\" quality={quality}"
|
|
887
|
+
), states
|
|
888
|
+
else:
|
|
889
|
+
allowed = entry_state.percent_b >= 0.50
|
|
890
|
+
if not allowed:
|
|
891
|
+
return False, (
|
|
892
|
+
f"mode=scalp side=short entry_tf={entry_state.interval} %B={entry_state.percent_b:.3f} "
|
|
893
|
+
f"result=REJECT reason=\"short rejected: entry interval below middle band\" quality={quality}"
|
|
894
|
+
), states
|
|
895
|
+
|
|
896
|
+
return True, (
|
|
897
|
+
f"mode=scalp side={normalized_side} entry_tf={entry_state.interval} %B={entry_state.percent_b:.3f} "
|
|
898
|
+
f"basis={entry_state.basis:.8f} upper={entry_state.upper:.8f} lower={entry_state.lower:.8f} "
|
|
899
|
+
f"result=PASS quality={quality}"
|
|
900
|
+
), states
|
|
901
|
+
|
|
902
|
+
regime_state = states.get("regime")
|
|
903
|
+
setup_state = states.get("setup")
|
|
904
|
+
entry_state = states["entry"]
|
|
905
|
+
|
|
906
|
+
if normalized_side == "long":
|
|
907
|
+
if regime_state is not None:
|
|
908
|
+
if regime_state.percent_b < 0.25 and regime_state.basis_slope < 0.0:
|
|
909
|
+
return False, (
|
|
910
|
+
f"mode=non_scalp side=long regime_tf={regime_state.interval} setup_tf="
|
|
911
|
+
f"{setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
|
|
912
|
+
f"result=REJECT reason=\"long rejected: regime interval is in bearish breakdown\""
|
|
913
|
+
), states
|
|
914
|
+
if regime_state.percent_b < 0.0 and regime_state.bandwidth_expanding:
|
|
915
|
+
return False, (
|
|
916
|
+
f"mode=non_scalp side=long regime_tf={regime_state.interval} setup_tf="
|
|
917
|
+
f"{setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
|
|
918
|
+
f"result=REJECT reason=\"long rejected: regime interval is below lower band with expanding bandwidth\""
|
|
919
|
+
), states
|
|
920
|
+
if setup_state is not None and setup_state.percent_b > 0.90:
|
|
921
|
+
return False, (
|
|
922
|
+
f"mode=non_scalp side=long regime_tf={regime_state.interval if regime_state is not None else 'N/A'} "
|
|
923
|
+
f"setup_tf={setup_state.interval} entry_tf={entry_state.interval} "
|
|
924
|
+
f"result=REJECT reason=\"long rejected: setup interval is already overextended high\""
|
|
925
|
+
), states
|
|
926
|
+
if entry_state.percent_b > 0.50:
|
|
927
|
+
return False, (
|
|
928
|
+
f"mode=non_scalp side=long regime_tf={regime_state.interval if regime_state is not None else 'N/A'} "
|
|
929
|
+
f"setup_tf={setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
|
|
930
|
+
f"result=REJECT reason=\"long rejected: entry interval above middle band\""
|
|
931
|
+
), states
|
|
932
|
+
else:
|
|
933
|
+
if regime_state is not None:
|
|
934
|
+
if regime_state.percent_b > 0.75 and regime_state.basis_slope > 0.0:
|
|
935
|
+
return False, (
|
|
936
|
+
f"mode=non_scalp side=short regime_tf={regime_state.interval} setup_tf="
|
|
937
|
+
f"{setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
|
|
938
|
+
f"result=REJECT reason=\"short rejected: regime interval is in bullish breakout\""
|
|
939
|
+
), states
|
|
940
|
+
if regime_state.percent_b > 1.0 and regime_state.bandwidth_expanding:
|
|
941
|
+
return False, (
|
|
942
|
+
f"mode=non_scalp side=short regime_tf={regime_state.interval} setup_tf="
|
|
943
|
+
f"{setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
|
|
944
|
+
f"result=REJECT reason=\"short rejected: regime interval is above upper band with expanding bandwidth\""
|
|
945
|
+
), states
|
|
946
|
+
if setup_state is not None and setup_state.percent_b < 0.10:
|
|
947
|
+
return False, (
|
|
948
|
+
f"mode=non_scalp side=short regime_tf={regime_state.interval if regime_state is not None else 'N/A'} "
|
|
949
|
+
f"setup_tf={setup_state.interval} entry_tf={entry_state.interval} "
|
|
950
|
+
f"result=REJECT reason=\"short rejected: setup interval is already overextended low\""
|
|
951
|
+
), states
|
|
952
|
+
if entry_state.percent_b < 0.50:
|
|
953
|
+
return False, (
|
|
954
|
+
f"mode=non_scalp side=short regime_tf={regime_state.interval if regime_state is not None else 'N/A'} "
|
|
955
|
+
f"setup_tf={setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
|
|
956
|
+
f"result=REJECT reason=\"short rejected: entry interval below middle band\""
|
|
957
|
+
), states
|
|
958
|
+
|
|
959
|
+
reason = (
|
|
960
|
+
f"mode=non_scalp side={normalized_side} "
|
|
961
|
+
f"regime_tf={regime_state.interval if regime_state is not None else 'N/A'} "
|
|
962
|
+
f"setup_tf={setup_state.interval if setup_state is not None else 'N/A'} "
|
|
963
|
+
f"entry_tf={entry_state.interval} result=PASS "
|
|
964
|
+
)
|
|
965
|
+
if regime_state is not None:
|
|
966
|
+
reason += f"regime_b={regime_state.percent_b:.3f} "
|
|
967
|
+
if setup_state is not None:
|
|
968
|
+
reason += f"setup_b={setup_state.percent_b:.3f} "
|
|
969
|
+
reason += f"entry_b={entry_state.percent_b:.3f}"
|
|
970
|
+
return True, reason, states
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
async def resolve_size_pct_from_active_asset_data(
|
|
974
|
+
info: Info,
|
|
975
|
+
account_address: str,
|
|
976
|
+
coin: str,
|
|
977
|
+
side: str,
|
|
978
|
+
size_pct_fraction: float,
|
|
979
|
+
) -> Tuple[Optional[float], str]:
|
|
980
|
+
"""Resolve size from Hyperliquid activeAssetData when available.
|
|
981
|
+
|
|
982
|
+
Hyperliquid's side-specific `maxTradeSzs` value already reflects the
|
|
983
|
+
leveraged trade-size ceiling for the asset. `availableToTrade` is useful
|
|
984
|
+
telemetry, but using `min(availableToTrade, maxTradeSzs)` here incorrectly
|
|
985
|
+
turns `--size-pct` into a percent of the unlevered side capacity.
|
|
986
|
+
"""
|
|
987
|
+
try:
|
|
988
|
+
active_asset_data = await info.post("/info", {"type": "activeAssetData", "user": account_address, "coin": coin})
|
|
989
|
+
except Exception as exc:
|
|
990
|
+
return None, f"activeAssetData request failed: {exc}"
|
|
991
|
+
|
|
992
|
+
if not isinstance(active_asset_data, dict):
|
|
993
|
+
return None, f"activeAssetData returned {type(active_asset_data).__name__}, expected dict"
|
|
994
|
+
|
|
995
|
+
side_idx = _active_asset_side_index(side)
|
|
996
|
+
available_to_trade = active_asset_data.get("availableToTrade")
|
|
997
|
+
# available_to_trade = active_asset_data.get("max_trade_notional_usd")
|
|
998
|
+
max_trade_szs = active_asset_data.get("maxTradeSzs")
|
|
999
|
+
if not isinstance(available_to_trade, (list, tuple)) or len(available_to_trade) < 2:
|
|
1000
|
+
return None, "activeAssetData.availableToTrade missing long/short capacity"
|
|
1001
|
+
if not isinstance(max_trade_szs, (list, tuple)) or len(max_trade_szs) < 2:
|
|
1002
|
+
return None, "activeAssetData.maxTradeSzs missing long/short capacity"
|
|
1003
|
+
|
|
1004
|
+
try:
|
|
1005
|
+
available_size = _decimal_from_margin_value(
|
|
1006
|
+
available_to_trade[side_idx],
|
|
1007
|
+
field_name=f"activeAssetData.availableToTrade[{side_idx}]",
|
|
1008
|
+
)
|
|
1009
|
+
max_trade_size = _decimal_from_margin_value(
|
|
1010
|
+
max_trade_szs[side_idx],
|
|
1011
|
+
field_name=f"activeAssetData.maxTradeSzs[{side_idx}]",
|
|
1012
|
+
)
|
|
1013
|
+
mark_price = _decimal_from_margin_value(
|
|
1014
|
+
active_asset_data.get("markPx"),
|
|
1015
|
+
field_name="activeAssetData.markPx",
|
|
1016
|
+
)
|
|
1017
|
+
except RuntimeError as exc:
|
|
1018
|
+
return None, f"activeAssetData numeric parse failed: {exc}"
|
|
1019
|
+
|
|
1020
|
+
if mark_price <= 0:
|
|
1021
|
+
return None, f"activeAssetData.markPx must be > 0, got {mark_price}"
|
|
1022
|
+
|
|
1023
|
+
size_pct_dec = decimal.Decimal(str(size_pct_fraction))
|
|
1024
|
+
capacity_size = max_trade_size
|
|
1025
|
+
derived_size = capacity_size * size_pct_dec
|
|
1026
|
+
rounded_size = await round_size_for_hyperliquid(info, coin, float(derived_size))
|
|
1027
|
+
if rounded_size <= 0.0:
|
|
1028
|
+
raise RuntimeError(
|
|
1029
|
+
f"--size-pct resolved to {float(derived_size):.12f} {coin} from activeAssetData, "
|
|
1030
|
+
"which rounds to zero for Hyperliquid precision."
|
|
1031
|
+
)
|
|
1032
|
+
return (
|
|
1033
|
+
rounded_size,
|
|
1034
|
+
(
|
|
1035
|
+
f"size_pct={size_pct_fraction:.4f} source=activeAssetData side={side} "
|
|
1036
|
+
f"available_size={available_size:.8f} max_trade_size={max_trade_size:.8f} "
|
|
1037
|
+
f"capacity_size={capacity_size:.8f} derived_size={derived_size:.8f} mark={mark_price:.8f}"
|
|
1038
|
+
),
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _extract_active_asset_data_leverage(active_asset_data: Dict[str, Any]) -> Tuple[Optional[decimal.Decimal], str]:
|
|
1043
|
+
"""Extract the currently configured leverage from activeAssetData when present."""
|
|
1044
|
+
leverage = active_asset_data.get("leverage")
|
|
1045
|
+
if not isinstance(leverage, dict):
|
|
1046
|
+
return None, "activeAssetData.leverage missing"
|
|
1047
|
+
try:
|
|
1048
|
+
leverage_value = _decimal_from_margin_value(
|
|
1049
|
+
leverage.get("value"),
|
|
1050
|
+
field_name="activeAssetData.leverage.value",
|
|
1051
|
+
)
|
|
1052
|
+
except RuntimeError as exc:
|
|
1053
|
+
return None, f"activeAssetData leverage parse failed: {exc}"
|
|
1054
|
+
if leverage_value <= 0:
|
|
1055
|
+
return None, f"activeAssetData leverage must be > 0, got {leverage_value}"
|
|
1056
|
+
return leverage_value, "activeAssetData.leverage.value"
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
async def get_instrument_leverage_for_size_pct(
|
|
1060
|
+
info: Info,
|
|
1061
|
+
account_address: str,
|
|
1062
|
+
coin: str,
|
|
1063
|
+
) -> Tuple[Optional[decimal.Decimal], str]:
|
|
1064
|
+
"""Return leverage for manual --size-pct fallback sizing."""
|
|
1065
|
+
try:
|
|
1066
|
+
active_asset_data = await info.post("/info", {"type": "activeAssetData", "user": account_address, "coin": coin})
|
|
1067
|
+
except Exception as exc:
|
|
1068
|
+
active_asset_data = None
|
|
1069
|
+
active_asset_reason = f"activeAssetData request failed: {exc}"
|
|
1070
|
+
else:
|
|
1071
|
+
if isinstance(active_asset_data, dict):
|
|
1072
|
+
leverage_value, leverage_source = _extract_active_asset_data_leverage(active_asset_data)
|
|
1073
|
+
if leverage_value is not None:
|
|
1074
|
+
return leverage_value, leverage_source
|
|
1075
|
+
active_asset_reason = leverage_source
|
|
1076
|
+
else:
|
|
1077
|
+
active_asset_reason = f"activeAssetData returned {type(active_asset_data).__name__}, expected dict"
|
|
1078
|
+
|
|
1079
|
+
try:
|
|
1080
|
+
meta = await info.meta()
|
|
1081
|
+
except Exception as exc:
|
|
1082
|
+
return None, f"{active_asset_reason}; meta request failed: {exc}"
|
|
1083
|
+
|
|
1084
|
+
universe = meta.get("universe", []) if isinstance(meta, dict) else []
|
|
1085
|
+
if not isinstance(universe, list):
|
|
1086
|
+
return None, f"{active_asset_reason}; meta.universe missing"
|
|
1087
|
+
|
|
1088
|
+
normalized_coin = str(coin).upper()
|
|
1089
|
+
for asset_info in universe:
|
|
1090
|
+
if not isinstance(asset_info, dict):
|
|
1091
|
+
continue
|
|
1092
|
+
asset_name = str(asset_info.get("name") or "").upper()
|
|
1093
|
+
if asset_name != normalized_coin:
|
|
1094
|
+
continue
|
|
1095
|
+
for key in ("maxLeverage", "maxLev", "max_leverage", "leverage"):
|
|
1096
|
+
raw_value = asset_info.get(key)
|
|
1097
|
+
if raw_value is None:
|
|
1098
|
+
continue
|
|
1099
|
+
try:
|
|
1100
|
+
leverage_value = _decimal_from_margin_value(raw_value, field_name=f"meta.universe[{normalized_coin}].{key}")
|
|
1101
|
+
except RuntimeError:
|
|
1102
|
+
continue
|
|
1103
|
+
if leverage_value > 0:
|
|
1104
|
+
return leverage_value, f"meta.universe.{key}"
|
|
1105
|
+
return None, f"{active_asset_reason}; no leverage field found in meta for {normalized_coin}"
|
|
1106
|
+
|
|
1107
|
+
return None, f"{active_asset_reason}; {normalized_coin} missing from meta universe"
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
async def resolve_auto_trade_size(
|
|
1114
|
+
info: Info,
|
|
1115
|
+
account_address: str,
|
|
1116
|
+
coin: str,
|
|
1117
|
+
side: str,
|
|
1118
|
+
size: Optional[float],
|
|
1119
|
+
size_pct: Optional[Any],
|
|
1120
|
+
) -> Tuple[float, str]:
|
|
1121
|
+
"""Resolve auto-trade size from explicit size or available-collateral percentage."""
|
|
1122
|
+
if size is not None:
|
|
1123
|
+
if size <= 0.0:
|
|
1124
|
+
raise RuntimeError("--size must be > 0.")
|
|
1125
|
+
return size, f"fixed size={size:.8f}"
|
|
1126
|
+
|
|
1127
|
+
if size_pct is None:
|
|
1128
|
+
raise RuntimeError("Specify either --size or --size-pct.")
|
|
1129
|
+
size_pct_fraction = parse_fractional_pct(size_pct, field_name="--size-pct")
|
|
1130
|
+
|
|
1131
|
+
active_asset_size, active_asset_reason = await resolve_size_pct_from_active_asset_data(
|
|
1132
|
+
info=info,
|
|
1133
|
+
account_address=account_address,
|
|
1134
|
+
coin=coin,
|
|
1135
|
+
side=side,
|
|
1136
|
+
size_pct_fraction=size_pct_fraction,
|
|
1137
|
+
)
|
|
1138
|
+
if active_asset_size is not None:
|
|
1139
|
+
return active_asset_size, active_asset_reason
|
|
1140
|
+
|
|
1141
|
+
user_state = await get_user_state_with_retry(
|
|
1142
|
+
info,
|
|
1143
|
+
account_address,
|
|
1144
|
+
context_label="resolve_auto_trade_size",
|
|
1145
|
+
coin=coin,
|
|
1146
|
+
)
|
|
1147
|
+
available_collateral, collateral_source = extract_available_collateral_from_user_state(user_state)
|
|
1148
|
+
if available_collateral is None or available_collateral <= 0.0:
|
|
1149
|
+
raise RuntimeError("Could not determine available collateral for --size-pct sizing.")
|
|
1150
|
+
|
|
1151
|
+
mids = await get_all_mids(info)
|
|
1152
|
+
current_px = _try_float(mids.get(coin))
|
|
1153
|
+
if current_px is None or current_px <= 0.0:
|
|
1154
|
+
raise RuntimeError(f"No mid price available for {coin}; cannot derive size from --size-pct.")
|
|
1155
|
+
|
|
1156
|
+
available_collateral_dec = decimal.Decimal(str(available_collateral))
|
|
1157
|
+
size_pct_dec = decimal.Decimal(str(size_pct_fraction))
|
|
1158
|
+
current_px_dec = decimal.Decimal(str(current_px))
|
|
1159
|
+
leverage_dec, leverage_source = await get_instrument_leverage_for_size_pct(
|
|
1160
|
+
info=info,
|
|
1161
|
+
account_address=account_address,
|
|
1162
|
+
coin=coin,
|
|
1163
|
+
)
|
|
1164
|
+
if leverage_dec is None or leverage_dec <= 0:
|
|
1165
|
+
raise RuntimeError(
|
|
1166
|
+
f"Could not determine leverage for {coin} during --size-pct sizing. "
|
|
1167
|
+
f"fallback_reason={active_asset_reason}; leverage_reason={leverage_source}"
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
usd_notional = available_collateral_dec * leverage_dec * size_pct_dec
|
|
1171
|
+
derived_size = usd_notional / current_px_dec
|
|
1172
|
+
rounded_size = await round_size_for_hyperliquid(info, coin, float(derived_size))
|
|
1173
|
+
if rounded_size <= 0.0:
|
|
1174
|
+
raise RuntimeError(
|
|
1175
|
+
f"--size-pct resolved to {float(derived_size):.12f} {coin}, which rounds to zero for Hyperliquid precision."
|
|
1176
|
+
)
|
|
1177
|
+
return (
|
|
1178
|
+
rounded_size,
|
|
1179
|
+
(
|
|
1180
|
+
f"size_pct={size_pct_fraction:.4f} collateral={available_collateral_dec:.8f} "
|
|
1181
|
+
f"source={collateral_source} leverage={leverage_dec:.8f} leverage_source={leverage_source} "
|
|
1182
|
+
f"notional={usd_notional:.8f} mid={current_px_dec:.8f} fallback_reason={active_asset_reason}"
|
|
1183
|
+
),
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def last_finite_index(*arrays: Any) -> int:
|
|
1188
|
+
"""Return the latest index where every TA-Lib array has a finite value."""
|
|
1189
|
+
if not arrays:
|
|
1190
|
+
raise RuntimeError("No arrays supplied for finite-index scan.")
|
|
1191
|
+
length = min(len(array) for array in arrays)
|
|
1192
|
+
for idx in range(length - 1, -1, -1):
|
|
1193
|
+
ok = True
|
|
1194
|
+
for array in arrays:
|
|
1195
|
+
try:
|
|
1196
|
+
value = float(array[idx])
|
|
1197
|
+
except (TypeError, ValueError):
|
|
1198
|
+
ok = False
|
|
1199
|
+
break
|
|
1200
|
+
if not math.isfinite(value):
|
|
1201
|
+
ok = False
|
|
1202
|
+
break
|
|
1203
|
+
if ok:
|
|
1204
|
+
return idx
|
|
1205
|
+
raise RuntimeError("No finite TA-Lib indicator row is available yet; fetch more candles.")
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
async def compute_auto_interval_signal(
|
|
1209
|
+
info: Info,
|
|
1210
|
+
coin: str,
|
|
1211
|
+
interval: str,
|
|
1212
|
+
periods: int,
|
|
1213
|
+
adx_threshold: float,
|
|
1214
|
+
macd_fast: int,
|
|
1215
|
+
macd_slow: int,
|
|
1216
|
+
macd_signal: int,
|
|
1217
|
+
sar_acceleration: float,
|
|
1218
|
+
sar_maximum: float,
|
|
1219
|
+
adx_timeperiod: int,
|
|
1220
|
+
bb_timeperiod: int,
|
|
1221
|
+
bb_dev: float,
|
|
1222
|
+
use_last_closed_candle: bool,
|
|
1223
|
+
use_websocket_candles: bool = False,
|
|
1224
|
+
) -> AutoIntervalSignal:
|
|
1225
|
+
"""Fetch candles for one interval and compute MACD/SAR/ADX/Bollinger signal state."""
|
|
1226
|
+
require_talib_available()
|
|
1227
|
+
minimum_periods = max(macd_slow + macd_signal + 5, adx_timeperiod + 5, bb_timeperiod + 5, 40)
|
|
1228
|
+
if periods < minimum_periods:
|
|
1229
|
+
raise RuntimeError(f"--auto-periods must be at least {minimum_periods} for the chosen indicator settings.")
|
|
1230
|
+
# TODO: Determine - is it possible to retrieve candles for multiple coins with one API call? Or is this data \
|
|
1231
|
+
# TODO: that is available over the websocket?
|
|
1232
|
+
candles = await fetch_recent_candles(
|
|
1233
|
+
info,
|
|
1234
|
+
coin,
|
|
1235
|
+
interval,
|
|
1236
|
+
periods,
|
|
1237
|
+
use_websocket_candles=use_websocket_candles,
|
|
1238
|
+
)
|
|
1239
|
+
usable_candles = candles[:-1] if use_last_closed_candle and len(candles) > 1 else candles
|
|
1240
|
+
|
|
1241
|
+
highs: List[float] = []
|
|
1242
|
+
lows: List[float] = []
|
|
1243
|
+
closes: List[float] = []
|
|
1244
|
+
for candle in usable_candles:
|
|
1245
|
+
try:
|
|
1246
|
+
highs.append(float(candle["h"]))
|
|
1247
|
+
lows.append(float(candle["l"]))
|
|
1248
|
+
closes.append(float(candle["c"]))
|
|
1249
|
+
except (KeyError, TypeError, ValueError):
|
|
1250
|
+
continue
|
|
1251
|
+
|
|
1252
|
+
if len(closes) < minimum_periods:
|
|
1253
|
+
raise RuntimeError(
|
|
1254
|
+
f"Not enough valid candles for {coin} {interval}: got {len(closes)}, need at least {minimum_periods}."
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
high_arr = np.asarray(highs, dtype=float) # type: ignore[union-attr]
|
|
1258
|
+
low_arr = np.asarray(lows, dtype=float) # type: ignore[union-attr]
|
|
1259
|
+
close_arr = np.asarray(closes, dtype=float) # type: ignore[union-attr]
|
|
1260
|
+
|
|
1261
|
+
macd_arr, macd_sig_arr, macd_hist_arr = talib.MACD( # type: ignore[union-attr]
|
|
1262
|
+
close_arr,
|
|
1263
|
+
fastperiod=macd_fast,
|
|
1264
|
+
slowperiod=macd_slow,
|
|
1265
|
+
signalperiod=macd_signal,
|
|
1266
|
+
)
|
|
1267
|
+
sar_arr = talib.SAR( # type: ignore[union-attr]
|
|
1268
|
+
high_arr,
|
|
1269
|
+
low_arr,
|
|
1270
|
+
acceleration=sar_acceleration,
|
|
1271
|
+
maximum=sar_maximum,
|
|
1272
|
+
)
|
|
1273
|
+
adx_arr = talib.ADX( # type: ignore[union-attr]
|
|
1274
|
+
high_arr,
|
|
1275
|
+
low_arr,
|
|
1276
|
+
close_arr,
|
|
1277
|
+
timeperiod=adx_timeperiod,
|
|
1278
|
+
)
|
|
1279
|
+
bb_upper_arr, bb_middle_arr, bb_lower_arr = talib.BBANDS( # type: ignore[union-attr]
|
|
1280
|
+
close_arr,
|
|
1281
|
+
timeperiod=bb_timeperiod,
|
|
1282
|
+
nbdevup=bb_dev,
|
|
1283
|
+
nbdevdn=bb_dev,
|
|
1284
|
+
matype=0,
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
idx = last_finite_index(close_arr, macd_arr, macd_sig_arr, macd_hist_arr, sar_arr, adx_arr, bb_upper_arr, bb_middle_arr, bb_lower_arr)
|
|
1288
|
+
close_px = float(close_arr[idx])
|
|
1289
|
+
macd_value = float(macd_arr[idx])
|
|
1290
|
+
macd_signal_value = float(macd_sig_arr[idx])
|
|
1291
|
+
macd_hist_value = float(macd_hist_arr[idx])
|
|
1292
|
+
sar_value = float(sar_arr[idx])
|
|
1293
|
+
adx_value = float(adx_arr[idx])
|
|
1294
|
+
bb_upper = float(bb_upper_arr[idx])
|
|
1295
|
+
bb_middle = float(bb_middle_arr[idx])
|
|
1296
|
+
bb_lower = float(bb_lower_arr[idx])
|
|
1297
|
+
|
|
1298
|
+
macd_bullish = macd_value > macd_signal_value and macd_hist_value > 0.0
|
|
1299
|
+
macd_bearish = macd_value < macd_signal_value and macd_hist_value < 0.0
|
|
1300
|
+
sar_bullish = close_px > sar_value
|
|
1301
|
+
sar_bearish = close_px < sar_value
|
|
1302
|
+
trend_ok = adx_value >= adx_threshold
|
|
1303
|
+
|
|
1304
|
+
if trend_ok and macd_bullish and sar_bullish:
|
|
1305
|
+
direction = "long"
|
|
1306
|
+
elif trend_ok and macd_bearish and sar_bearish:
|
|
1307
|
+
direction = "short"
|
|
1308
|
+
else:
|
|
1309
|
+
direction = "neutral"
|
|
1310
|
+
|
|
1311
|
+
reason = (
|
|
1312
|
+
f"macd={'bull' if macd_bullish else 'bear' if macd_bearish else 'flat'} "
|
|
1313
|
+
f"sar={'bull' if sar_bullish else 'bear' if sar_bearish else 'flat'} "
|
|
1314
|
+
f"adx={adx_value:.2f}/{adx_threshold:.2f}"
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
return AutoIntervalSignal(
|
|
1318
|
+
interval=interval,
|
|
1319
|
+
closes=list(closes),
|
|
1320
|
+
close=close_px,
|
|
1321
|
+
macd=macd_value,
|
|
1322
|
+
macd_signal=macd_signal_value,
|
|
1323
|
+
macd_hist=macd_hist_value,
|
|
1324
|
+
sar=sar_value,
|
|
1325
|
+
adx=adx_value,
|
|
1326
|
+
bb_upper=bb_upper,
|
|
1327
|
+
bb_middle=bb_middle,
|
|
1328
|
+
bb_lower=bb_lower,
|
|
1329
|
+
direction=direction,
|
|
1330
|
+
reason=reason,
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def clamp_pct(value: float, minimum: float, maximum: float) -> float:
|
|
1335
|
+
"""Clamp a percentage fraction to configured bounds."""
|
|
1336
|
+
return min(max(value, minimum), maximum)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def get_shortest_interval_snapshot(snapshots: List[AutoIntervalSignal]) -> Optional[AutoIntervalSignal]:
|
|
1340
|
+
"""Return the snapshot for the shortest configured interval."""
|
|
1341
|
+
if not snapshots:
|
|
1342
|
+
return None
|
|
1343
|
+
return min(snapshots, key=lambda snapshot: INTERVAL_TO_MS.get(snapshot.interval, math.inf))
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
async def evaluate_auto_trade_decision(
|
|
1349
|
+
info: Info,
|
|
1350
|
+
coin: str,
|
|
1351
|
+
intervals: List[str],
|
|
1352
|
+
periods: int,
|
|
1353
|
+
min_agreement: int,
|
|
1354
|
+
adx_threshold: float,
|
|
1355
|
+
take_profit_pct_override: Optional[float],
|
|
1356
|
+
stop_loss_pct_override: Optional[float],
|
|
1357
|
+
min_take_profit_pct: float,
|
|
1358
|
+
max_take_profit_pct: float,
|
|
1359
|
+
macd_fast: int,
|
|
1360
|
+
macd_slow: int,
|
|
1361
|
+
macd_signal_period: int,
|
|
1362
|
+
sar_acceleration: float,
|
|
1363
|
+
sar_maximum: float,
|
|
1364
|
+
adx_timeperiod: int,
|
|
1365
|
+
bb_timeperiod: int,
|
|
1366
|
+
bb_dev: float,
|
|
1367
|
+
use_last_closed_candle: bool,
|
|
1368
|
+
use_sar_stop_on_shortest_interval: bool,
|
|
1369
|
+
use_websocket_candles: bool = False,
|
|
1370
|
+
current_px: Optional[float] = None,
|
|
1371
|
+
) -> AutoTradeDecision:
|
|
1372
|
+
"""Evaluate all configured intervals and return an aggregated trade decision."""
|
|
1373
|
+
snapshots: List[AutoIntervalSignal] = []
|
|
1374
|
+
errors: List[str] = []
|
|
1375
|
+
for interval in intervals:
|
|
1376
|
+
try:
|
|
1377
|
+
snapshots.append(
|
|
1378
|
+
await compute_auto_interval_signal(
|
|
1379
|
+
info=info,
|
|
1380
|
+
coin=coin,
|
|
1381
|
+
interval=interval,
|
|
1382
|
+
periods=periods,
|
|
1383
|
+
adx_threshold=adx_threshold,
|
|
1384
|
+
macd_fast=macd_fast,
|
|
1385
|
+
macd_slow=macd_slow,
|
|
1386
|
+
macd_signal=macd_signal_period,
|
|
1387
|
+
sar_acceleration=sar_acceleration,
|
|
1388
|
+
sar_maximum=sar_maximum,
|
|
1389
|
+
adx_timeperiod=adx_timeperiod,
|
|
1390
|
+
bb_timeperiod=bb_timeperiod,
|
|
1391
|
+
bb_dev=bb_dev,
|
|
1392
|
+
use_last_closed_candle=use_last_closed_candle,
|
|
1393
|
+
use_websocket_candles=use_websocket_candles,
|
|
1394
|
+
)
|
|
1395
|
+
)
|
|
1396
|
+
except Exception as exc:
|
|
1397
|
+
errors.append(f"{interval}: {exc}")
|
|
1398
|
+
cp.warning(f"[AUTO-WARN] Failed to compute signal for {coin} {interval}: {exc}")
|
|
1399
|
+
|
|
1400
|
+
if not snapshots:
|
|
1401
|
+
return AutoTradeDecision(
|
|
1402
|
+
direction=None,
|
|
1403
|
+
current_px=0.0,
|
|
1404
|
+
target_px=None,
|
|
1405
|
+
take_profit_pct=None,
|
|
1406
|
+
stop_loss_pct=None,
|
|
1407
|
+
stop_loss_trigger_px=None,
|
|
1408
|
+
long_votes=0,
|
|
1409
|
+
short_votes=0,
|
|
1410
|
+
required_votes=max(1, min_agreement),
|
|
1411
|
+
snapshots=[],
|
|
1412
|
+
reason="No usable interval snapshots. " + "; ".join(errors),
|
|
1413
|
+
)
|
|
1414
|
+
|
|
1415
|
+
required_votes = len(intervals) if min_agreement <= 0 else min_agreement
|
|
1416
|
+
long_votes = sum(1 for snapshot in snapshots if snapshot.direction == "long")
|
|
1417
|
+
short_votes = sum(1 for snapshot in snapshots if snapshot.direction == "short")
|
|
1418
|
+
|
|
1419
|
+
if current_px is None or current_px <= 0.0:
|
|
1420
|
+
current_px = float(snapshots[-1].close)
|
|
1421
|
+
|
|
1422
|
+
if len(snapshots) < required_votes:
|
|
1423
|
+
return AutoTradeDecision(
|
|
1424
|
+
direction=None,
|
|
1425
|
+
current_px=current_px,
|
|
1426
|
+
target_px=None,
|
|
1427
|
+
take_profit_pct=None,
|
|
1428
|
+
stop_loss_pct=None,
|
|
1429
|
+
stop_loss_trigger_px=None,
|
|
1430
|
+
long_votes=long_votes,
|
|
1431
|
+
short_votes=short_votes,
|
|
1432
|
+
required_votes=required_votes,
|
|
1433
|
+
snapshots=snapshots,
|
|
1434
|
+
reason=f"Only {len(snapshots)} usable snapshots; required {required_votes}.",
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
direction: Optional[str]
|
|
1438
|
+
if long_votes >= required_votes and long_votes > short_votes:
|
|
1439
|
+
direction = "long"
|
|
1440
|
+
elif short_votes >= required_votes and short_votes > long_votes:
|
|
1441
|
+
direction = "short"
|
|
1442
|
+
else:
|
|
1443
|
+
direction = None
|
|
1444
|
+
|
|
1445
|
+
if direction is None:
|
|
1446
|
+
return AutoTradeDecision(
|
|
1447
|
+
direction=None,
|
|
1448
|
+
current_px=current_px,
|
|
1449
|
+
target_px=None,
|
|
1450
|
+
take_profit_pct=None,
|
|
1451
|
+
stop_loss_pct=None,
|
|
1452
|
+
stop_loss_trigger_px=None,
|
|
1453
|
+
long_votes=long_votes,
|
|
1454
|
+
short_votes=short_votes,
|
|
1455
|
+
required_votes=required_votes,
|
|
1456
|
+
snapshots=snapshots,
|
|
1457
|
+
reason=f"No trade: long_votes={long_votes}, short_votes={short_votes}, required={required_votes}.",
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
if direction == "long":
|
|
1461
|
+
candidate_targets = sorted(snapshot.bb_upper for snapshot in snapshots if snapshot.bb_upper > current_px)
|
|
1462
|
+
raw_target_px = candidate_targets[0] if candidate_targets else current_px * (1.0 + min_take_profit_pct)
|
|
1463
|
+
raw_take_profit_pct = max(0.0, (raw_target_px - current_px) / current_px)
|
|
1464
|
+
else:
|
|
1465
|
+
candidate_targets = sorted(
|
|
1466
|
+
(snapshot.bb_lower for snapshot in snapshots if snapshot.bb_lower < current_px),
|
|
1467
|
+
reverse=True,
|
|
1468
|
+
)
|
|
1469
|
+
raw_target_px = candidate_targets[0] if candidate_targets else current_px * (1.0 - min_take_profit_pct)
|
|
1470
|
+
raw_take_profit_pct = max(0.0, (current_px - raw_target_px) / current_px)
|
|
1471
|
+
|
|
1472
|
+
if take_profit_pct_override is not None:
|
|
1473
|
+
take_profit_pct = take_profit_pct_override
|
|
1474
|
+
else:
|
|
1475
|
+
take_profit_pct = clamp_pct(raw_take_profit_pct, min_take_profit_pct, max_take_profit_pct)
|
|
1476
|
+
|
|
1477
|
+
if direction == "long":
|
|
1478
|
+
target_px = current_px * (1.0 + take_profit_pct)
|
|
1479
|
+
else:
|
|
1480
|
+
target_px = current_px * (1.0 - take_profit_pct)
|
|
1481
|
+
|
|
1482
|
+
stop_loss_pct = compute_default_stop_loss_pct(take_profit_pct, stop_loss_pct_override)
|
|
1483
|
+
stop_loss_trigger_px: Optional[float] = None
|
|
1484
|
+
shortest_snapshot = get_shortest_interval_snapshot(snapshots)
|
|
1485
|
+
if use_sar_stop_on_shortest_interval and shortest_snapshot is not None:
|
|
1486
|
+
candidate_stop = float(shortest_snapshot.sar)
|
|
1487
|
+
if direction == "long" and candidate_stop < current_px:
|
|
1488
|
+
stop_loss_trigger_px = candidate_stop
|
|
1489
|
+
elif direction == "short" and candidate_stop > current_px:
|
|
1490
|
+
stop_loss_trigger_px = candidate_stop
|
|
1491
|
+
return AutoTradeDecision(
|
|
1492
|
+
direction=direction,
|
|
1493
|
+
current_px=current_px,
|
|
1494
|
+
target_px=target_px,
|
|
1495
|
+
take_profit_pct=take_profit_pct,
|
|
1496
|
+
stop_loss_pct=stop_loss_pct,
|
|
1497
|
+
stop_loss_trigger_px=stop_loss_trigger_px,
|
|
1498
|
+
long_votes=long_votes,
|
|
1499
|
+
short_votes=short_votes,
|
|
1500
|
+
required_votes=required_votes,
|
|
1501
|
+
snapshots=snapshots,
|
|
1502
|
+
reason=(
|
|
1503
|
+
f"{direction.upper()} signal: long_votes={long_votes}, short_votes={short_votes}, "
|
|
1504
|
+
f"required={required_votes}, bb_target={raw_target_px:.8f}, tp_pct={take_profit_pct * 100:.4f}%"
|
|
1505
|
+
),
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def print_auto_decision(decision: AutoTradeDecision, instrument: str) -> None:
|
|
1510
|
+
"""Print a compact multi-timeframe signal summary."""
|
|
1511
|
+
cp.normal(f"{instrument.upper()} Interval signals:", 'AUTO')
|
|
1512
|
+
if not decision.snapshots:
|
|
1513
|
+
print(" no usable snapshots")
|
|
1514
|
+
for snapshot in decision.snapshots:
|
|
1515
|
+
print(
|
|
1516
|
+
f" {snapshot.interval:>4} | {snapshot.direction:>7} | close={snapshot.close:.8f} "
|
|
1517
|
+
f"macd={snapshot.macd:.8f}/{snapshot.macd_signal:.8f} hist={snapshot.macd_hist:.8f} "
|
|
1518
|
+
f"sar={snapshot.sar:.8f} adx={snapshot.adx:.2f} "
|
|
1519
|
+
f"bb=({snapshot.bb_lower:.8f}, {snapshot.bb_middle:.8f}, {snapshot.bb_upper:.8f}) "
|
|
1520
|
+
f"{snapshot.reason}"
|
|
1521
|
+
)
|
|
1522
|
+
target = f"{decision.target_px:.8f}" if decision.target_px is not None else "N/A"
|
|
1523
|
+
tp_pct = f"{decision.take_profit_pct * 100:.4f}%" if decision.take_profit_pct is not None else "N/A"
|
|
1524
|
+
sl_pct = f"{decision.stop_loss_pct * 100:.4f}%" if decision.stop_loss_pct is not None else "N/A"
|
|
1525
|
+
sl_trigger = f"{decision.stop_loss_trigger_px:.8f}" if decision.stop_loss_trigger_px is not None else "N/A"
|
|
1526
|
+
data = f"decision={decision.direction or 'none'} current={decision.current_px:.8f} "
|
|
1527
|
+
f"target={target} tp={tp_pct} sl={sl_pct} sl_trigger={sl_trigger} reason={decision.reason}"
|
|
1528
|
+
if decision.direction is None:
|
|
1529
|
+
cp.warning(data, 'AUTO')
|
|
1530
|
+
else:
|
|
1531
|
+
if decision.direction.lower() == "long":
|
|
1532
|
+
cp.good(
|
|
1533
|
+
data, 'AUTO'
|
|
1534
|
+
)
|
|
1535
|
+
elif decision.direction.lower() == "short":
|
|
1536
|
+
cp.error(
|
|
1537
|
+
data, 'AUTO'
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
async def scan_auto_trade_candidate(
|
|
1542
|
+
info: Info,
|
|
1543
|
+
scan_coin: str,
|
|
1544
|
+
intervals: List[str],
|
|
1545
|
+
periods: int,
|
|
1546
|
+
min_agreement: int,
|
|
1547
|
+
adx_threshold: float,
|
|
1548
|
+
take_profit_pct: Optional[float],
|
|
1549
|
+
stop_loss_pct: Optional[float],
|
|
1550
|
+
min_take_profit_pct: float,
|
|
1551
|
+
max_take_profit_pct: float,
|
|
1552
|
+
macd_fast: int,
|
|
1553
|
+
macd_slow: int,
|
|
1554
|
+
macd_signal_period: int,
|
|
1555
|
+
sar_acceleration: float,
|
|
1556
|
+
sar_maximum: float,
|
|
1557
|
+
adx_timeperiod: int,
|
|
1558
|
+
bb_timeperiod: int,
|
|
1559
|
+
bb_dev: float,
|
|
1560
|
+
scalp: bool,
|
|
1561
|
+
use_last_closed_candle: bool,
|
|
1562
|
+
use_sar_stop_on_shortest_interval: bool,
|
|
1563
|
+
snapshot: AutoScanLoopSnapshot,
|
|
1564
|
+
use_websocket_candles: bool = False,
|
|
1565
|
+
) -> AutoScanCandidate:
|
|
1566
|
+
"""Scan one market using shared loop snapshots to avoid redundant REST calls."""
|
|
1567
|
+
existing_pos = snapshot.positions_by_coin.get(scan_coin.upper())
|
|
1568
|
+
if existing_pos is not None:
|
|
1569
|
+
return AutoScanCandidate(coin=scan_coin, decision=None, existing_position=existing_pos)
|
|
1570
|
+
|
|
1571
|
+
decision = await evaluate_auto_trade_decision(
|
|
1572
|
+
info=info,
|
|
1573
|
+
coin=scan_coin,
|
|
1574
|
+
intervals=intervals,
|
|
1575
|
+
periods=periods,
|
|
1576
|
+
min_agreement=min_agreement,
|
|
1577
|
+
adx_threshold=adx_threshold,
|
|
1578
|
+
take_profit_pct_override=take_profit_pct,
|
|
1579
|
+
stop_loss_pct_override=stop_loss_pct,
|
|
1580
|
+
min_take_profit_pct=min_take_profit_pct,
|
|
1581
|
+
max_take_profit_pct=max_take_profit_pct,
|
|
1582
|
+
macd_fast=macd_fast,
|
|
1583
|
+
macd_slow=macd_slow,
|
|
1584
|
+
macd_signal_period=macd_signal_period,
|
|
1585
|
+
sar_acceleration=sar_acceleration,
|
|
1586
|
+
sar_maximum=sar_maximum,
|
|
1587
|
+
adx_timeperiod=adx_timeperiod,
|
|
1588
|
+
bb_timeperiod=bb_timeperiod,
|
|
1589
|
+
bb_dev=bb_dev,
|
|
1590
|
+
use_last_closed_candle=use_last_closed_candle,
|
|
1591
|
+
use_sar_stop_on_shortest_interval=use_sar_stop_on_shortest_interval,
|
|
1592
|
+
use_websocket_candles=use_websocket_candles,
|
|
1593
|
+
current_px=snapshot.mids.get(scan_coin.upper()),
|
|
1594
|
+
)
|
|
1595
|
+
print_auto_decision(decision, scan_coin)
|
|
1596
|
+
|
|
1597
|
+
if decision.direction is None or decision.take_profit_pct is None:
|
|
1598
|
+
return AutoScanCandidate(coin=scan_coin, decision=decision, existing_position=None)
|
|
1599
|
+
|
|
1600
|
+
interval_to_closes = {interval_snapshot.interval: list(interval_snapshot.closes) for interval_snapshot in decision.snapshots}
|
|
1601
|
+
bb_allowed, bb_reason, _bb_states = confirm_signal_with_bollinger(
|
|
1602
|
+
side=decision.direction,
|
|
1603
|
+
interval_to_closes=interval_to_closes,
|
|
1604
|
+
active_intervals=intervals,
|
|
1605
|
+
scalp=scalp,
|
|
1606
|
+
period=bb_timeperiod,
|
|
1607
|
+
stddev_multiplier=bb_dev,
|
|
1608
|
+
)
|
|
1609
|
+
print(f"[BB] {scan_coin} {bb_reason}")
|
|
1610
|
+
if not bb_allowed:
|
|
1611
|
+
print(f"[AUTO] {scan_coin} Bollinger confirmation rejected; skipping entry this loop.")
|
|
1612
|
+
return AutoScanCandidate(
|
|
1613
|
+
coin=scan_coin,
|
|
1614
|
+
decision=decision,
|
|
1615
|
+
existing_position=None,
|
|
1616
|
+
rejection_reason="bollinger_confirmation_rejected",
|
|
1617
|
+
)
|
|
1618
|
+
|
|
1619
|
+
return AutoScanCandidate(coin=scan_coin, decision=decision, existing_position=None)
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
async def run_auto_trader(
|
|
1623
|
+
coin: Optional[str],
|
|
1624
|
+
size: Optional[float],
|
|
1625
|
+
size_pct: Optional[Any],
|
|
1626
|
+
top_markets: int,
|
|
1627
|
+
intervals_value: str,
|
|
1628
|
+
periods: int,
|
|
1629
|
+
scan_interval: float,
|
|
1630
|
+
max_concurrent_scans: int,
|
|
1631
|
+
min_agreement: int,
|
|
1632
|
+
adx_threshold: float,
|
|
1633
|
+
take_profit_pct: Optional[float],
|
|
1634
|
+
stop_loss_pct: Optional[float],
|
|
1635
|
+
min_take_profit_pct: float,
|
|
1636
|
+
max_take_profit_pct: float,
|
|
1637
|
+
take_profit_levels: int,
|
|
1638
|
+
use_trailing_tp: bool,
|
|
1639
|
+
trailing_tp_trigger_level: int,
|
|
1640
|
+
trailing_tp_profit_pct: float,
|
|
1641
|
+
entry_retries: int,
|
|
1642
|
+
entry_repost_interval: float,
|
|
1643
|
+
poll_interval: float,
|
|
1644
|
+
tp_reversal_pct: Optional[float],
|
|
1645
|
+
entry_tif: str,
|
|
1646
|
+
tp_tif: str,
|
|
1647
|
+
market_fallback: bool,
|
|
1648
|
+
market_slippage: float,
|
|
1649
|
+
cancel_existing_tpsl: bool,
|
|
1650
|
+
tp_reversal_limit_exit: bool,
|
|
1651
|
+
tp_reversal_stop_buffer_pct: Optional[float],
|
|
1652
|
+
macd_fast: int,
|
|
1653
|
+
macd_slow: int,
|
|
1654
|
+
macd_signal_period: int,
|
|
1655
|
+
sar_acceleration: float,
|
|
1656
|
+
sar_maximum: float,
|
|
1657
|
+
adx_timeperiod: int,
|
|
1658
|
+
bb_timeperiod: int,
|
|
1659
|
+
bb_dev: float,
|
|
1660
|
+
scalp: bool,
|
|
1661
|
+
use_last_closed_candle: bool,
|
|
1662
|
+
use_sar_stop_on_shortest_interval: bool,
|
|
1663
|
+
dry_run: bool,
|
|
1664
|
+
max_trades: int,
|
|
1665
|
+
cooldown_after_trade: float,
|
|
1666
|
+
loop_after_trade: bool,
|
|
1667
|
+
max_coin_trades_per_session: int,
|
|
1668
|
+
coin_session_cooldown_seconds: float,
|
|
1669
|
+
coin_session_profit_target: float,
|
|
1670
|
+
coin_session_min_profit_to_lock: float,
|
|
1671
|
+
coin_session_giveback_pct: float,
|
|
1672
|
+
cooldown_after_loss_following_wins: int,
|
|
1673
|
+
session_profit_target: float,
|
|
1674
|
+
session_max_loss: float,
|
|
1675
|
+
session_giveback_pct: float,
|
|
1676
|
+
use_testnet: bool,
|
|
1677
|
+
use_websocket: bool = True,
|
|
1678
|
+
use_websocket_candles: bool = False,
|
|
1679
|
+
hide_orders: bool = False,
|
|
1680
|
+
risk_session_log: str = "",
|
|
1681
|
+
account_address: Optional[str] = None,
|
|
1682
|
+
info: Optional[Info] = None,
|
|
1683
|
+
exchange: Optional[Exchange] = None,
|
|
1684
|
+
) -> None:
|
|
1685
|
+
"""Automatically scan TA-Lib signals, enter positions, and hand off to bracket management."""
|
|
1686
|
+
|
|
1687
|
+
require_talib_available()
|
|
1688
|
+
coin = coin.upper() if coin is not None else None
|
|
1689
|
+
intervals = parse_interval_list(intervals_value)
|
|
1690
|
+
if (size is None) == (size_pct is None):
|
|
1691
|
+
raise RuntimeError("Specify exactly one of --size or --size-pct.")
|
|
1692
|
+
if size is not None and size <= 0.0:
|
|
1693
|
+
raise RuntimeError("--size must be > 0.")
|
|
1694
|
+
size_pct_fraction: Optional[float] = None
|
|
1695
|
+
if size_pct is not None:
|
|
1696
|
+
size_pct_fraction = parse_fractional_pct(size_pct, field_name="--size-pct")
|
|
1697
|
+
if top_markets <= 0:
|
|
1698
|
+
raise RuntimeError("--top-markets must be > 0.")
|
|
1699
|
+
if periods <= 0:
|
|
1700
|
+
raise RuntimeError("--auto-periods must be > 0.")
|
|
1701
|
+
if scan_interval <= 0.0:
|
|
1702
|
+
raise RuntimeError("--scan-interval must be > 0.")
|
|
1703
|
+
if max_concurrent_scans <= 0:
|
|
1704
|
+
raise RuntimeError("--max-concurrent-scans must be > 0.")
|
|
1705
|
+
if min_agreement < 0:
|
|
1706
|
+
raise RuntimeError("--min-agreement must be >= 0. Use 0 to require all configured intervals.")
|
|
1707
|
+
if adx_threshold < 0.0:
|
|
1708
|
+
raise RuntimeError("--adx-threshold must be >= 0.")
|
|
1709
|
+
if take_profit_pct is not None and not (0.0 < take_profit_pct < 1.0):
|
|
1710
|
+
raise RuntimeError("--take-profit-pct must be between 0 and 1.")
|
|
1711
|
+
if stop_loss_pct is not None and not (0.0 < stop_loss_pct < 1.0):
|
|
1712
|
+
raise RuntimeError("--stop-loss-pct must be between 0 and 1.")
|
|
1713
|
+
if not (0.0 < min_take_profit_pct < 1.0):
|
|
1714
|
+
raise RuntimeError("--min-take-profit-pct must be between 0 and 1.")
|
|
1715
|
+
if not (0.0 < max_take_profit_pct < 1.0):
|
|
1716
|
+
raise RuntimeError("--max-take-profit-pct must be between 0 and 1.")
|
|
1717
|
+
if min_take_profit_pct > max_take_profit_pct:
|
|
1718
|
+
raise RuntimeError("--min-take-profit-pct cannot be greater than --max-take-profit-pct.")
|
|
1719
|
+
if take_profit_levels <= 0:
|
|
1720
|
+
raise RuntimeError("--take-profit-levels must be > 0.")
|
|
1721
|
+
if trailing_tp_trigger_level <= 0:
|
|
1722
|
+
raise RuntimeError("--trailing-tp-trigger-level must be > 0.")
|
|
1723
|
+
if trailing_tp_trigger_level > take_profit_levels:
|
|
1724
|
+
raise RuntimeError("--trailing-tp-trigger-level cannot exceed --take-profit-levels.")
|
|
1725
|
+
if not (0.0 < trailing_tp_profit_pct < 1.0):
|
|
1726
|
+
raise RuntimeError("--trailing-tp-profit-pct must be between 0 and 1.")
|
|
1727
|
+
if entry_retries < 0:
|
|
1728
|
+
raise RuntimeError("--entry-retries must be >= 0.")
|
|
1729
|
+
if entry_repost_interval <= 0.0:
|
|
1730
|
+
raise RuntimeError("--entry-repost-interval must be > 0.")
|
|
1731
|
+
if poll_interval <= 0.0:
|
|
1732
|
+
raise RuntimeError("--poll-interval must be > 0.")
|
|
1733
|
+
if max_trades < 0:
|
|
1734
|
+
raise RuntimeError("--max-trades must be >= 0.")
|
|
1735
|
+
if cooldown_after_trade < 0.0:
|
|
1736
|
+
raise RuntimeError("--cooldown-after-trade must be >= 0.")
|
|
1737
|
+
if max_coin_trades_per_session < 0:
|
|
1738
|
+
raise RuntimeError("--max-coin-trades-per-session must be >= 0.")
|
|
1739
|
+
if coin_session_cooldown_seconds < 0.0:
|
|
1740
|
+
raise RuntimeError("--coin-session-cooldown-seconds must be >= 0.")
|
|
1741
|
+
if coin_session_profit_target < 0.0:
|
|
1742
|
+
raise RuntimeError("--coin-session-profit-target must be >= 0.")
|
|
1743
|
+
if coin_session_min_profit_to_lock < 0.0:
|
|
1744
|
+
raise RuntimeError("--coin-session-min-profit-to-lock must be >= 0.")
|
|
1745
|
+
if not (0.0 <= coin_session_giveback_pct < 1.0):
|
|
1746
|
+
raise RuntimeError("--coin-session-giveback-pct must be >= 0 and < 1.")
|
|
1747
|
+
if cooldown_after_loss_following_wins < 0:
|
|
1748
|
+
raise RuntimeError("--cooldown-after-loss-following-wins must be >= 0.")
|
|
1749
|
+
if session_profit_target < 0.0:
|
|
1750
|
+
raise RuntimeError("--session-profit-target must be >= 0.")
|
|
1751
|
+
if session_max_loss < 0.0:
|
|
1752
|
+
raise RuntimeError("--session-max-loss must be >= 0.")
|
|
1753
|
+
if not (0.0 <= session_giveback_pct < 1.0):
|
|
1754
|
+
raise RuntimeError("--session-giveback-pct must be >= 0 and < 1.")
|
|
1755
|
+
if use_websocket_candles and not use_websocket:
|
|
1756
|
+
raise RuntimeError("--ws-candles requires websocket market data. Remove --no-websocket to enable it.")
|
|
1757
|
+
|
|
1758
|
+
owns_clients = account_address is None and info is None and exchange is None
|
|
1759
|
+
if not owns_clients and (account_address is None or info is None or exchange is None):
|
|
1760
|
+
raise RuntimeError("Pass account_address, info, and exchange together when reusing initialized clients.")
|
|
1761
|
+
completed_trades = 0
|
|
1762
|
+
auto_trades_logger = get_auto_trades_logger()
|
|
1763
|
+
|
|
1764
|
+
try:
|
|
1765
|
+
if owns_clients:
|
|
1766
|
+
account_address, info, exchange = await init_clients(use_testnet, use_websocket=use_websocket)
|
|
1767
|
+
metrics_start_time_ms = int(time.time() * 1000)
|
|
1768
|
+
risk_session = AutoRiskSessionState(started_ms=metrics_start_time_ms)
|
|
1769
|
+
coin_sessions: Dict[str, CoinTradeSessionState] = {}
|
|
1770
|
+
print("============================================================")
|
|
1771
|
+
print(" Hyperliquid Async Auto Trader")
|
|
1772
|
+
print("============================================================")
|
|
1773
|
+
print(f"Account: {account_address}")
|
|
1774
|
+
print(f"Network: {'TESTNET' if use_testnet else 'MAINNET'}")
|
|
1775
|
+
print(f"Websocket: {'ENABLED' if use_websocket else 'DISABLED'}")
|
|
1776
|
+
print(f"WS candles: {'ENABLED' if use_websocket_candles else 'DISABLED'}")
|
|
1777
|
+
print(f"Hide orders: {hide_orders}")
|
|
1778
|
+
print(f"Coin scope: {coin if coin is not None else f'TOP {top_markets} PERPS BY VOLUME'}")
|
|
1779
|
+
print(f"Size mode: {'fixed contracts' if size is not None else 'available collateral pct'}")
|
|
1780
|
+
if size is not None:
|
|
1781
|
+
print(f"Size: {size:.8f}")
|
|
1782
|
+
else:
|
|
1783
|
+
print(f"Size pct: {size_pct_fraction:.4f}")
|
|
1784
|
+
print(f"Intervals: {', '.join(intervals)}")
|
|
1785
|
+
print(f"Bollinger confirm: {'SCALP' if scalp else 'NON-SCALP'}")
|
|
1786
|
+
print(f"Required agreement: {'ALL' if min_agreement == 0 else min_agreement}")
|
|
1787
|
+
print(f"ADX threshold: {adx_threshold:.2f}")
|
|
1788
|
+
print(f"Bollinger: timeperiod={bb_timeperiod}, dev={bb_dev}")
|
|
1789
|
+
print(f"SAR stop mode: {use_sar_stop_on_shortest_interval}")
|
|
1790
|
+
print(f"Trailing TP: {use_trailing_tp}")
|
|
1791
|
+
if use_trailing_tp:
|
|
1792
|
+
print(f"Trailing TP level: {trailing_tp_trigger_level}")
|
|
1793
|
+
print(f"Trailing TP pct: {trailing_tp_profit_pct * 100:.4f}% of favorable unrealized profit")
|
|
1794
|
+
print(f"Scan interval: {scan_interval:.2f}s")
|
|
1795
|
+
print(f"Scan concurrency: {max_concurrent_scans}")
|
|
1796
|
+
print(f"Dry run: {dry_run}")
|
|
1797
|
+
print(f"Max trades: {'unlimited' if max_trades == 0 else max_trades}")
|
|
1798
|
+
print(f"Loop after trade: {loop_after_trade}")
|
|
1799
|
+
print("Coin risk controls:")
|
|
1800
|
+
print(f" max_coin_trades_per_session={max_coin_trades_per_session}")
|
|
1801
|
+
print(f" coin_session_cooldown_seconds={coin_session_cooldown_seconds}")
|
|
1802
|
+
print(f" coin_session_profit_target={coin_session_profit_target}")
|
|
1803
|
+
print(f" coin_session_min_profit_to_lock={coin_session_min_profit_to_lock}")
|
|
1804
|
+
print(f" coin_session_giveback_pct={coin_session_giveback_pct}")
|
|
1805
|
+
print(f" cooldown_after_loss_following_wins={cooldown_after_loss_following_wins}")
|
|
1806
|
+
print("Session risk controls:")
|
|
1807
|
+
print(f" session_profit_target={session_profit_target}")
|
|
1808
|
+
print(f" session_max_loss={session_max_loss}")
|
|
1809
|
+
print(f" session_giveback_pct={session_giveback_pct}")
|
|
1810
|
+
print("============================================================")
|
|
1811
|
+
|
|
1812
|
+
while True:
|
|
1813
|
+
if 0 < max_trades <= completed_trades:
|
|
1814
|
+
print(f"[AUTO] Max trades reached ({completed_trades}); exiting auto mode.")
|
|
1815
|
+
return
|
|
1816
|
+
|
|
1817
|
+
if coin is not None:
|
|
1818
|
+
scan_coins = [coin]
|
|
1819
|
+
else:
|
|
1820
|
+
ranked_markets = await get_top_perp_markets_by_volume(info, top_markets)
|
|
1821
|
+
scan_coins = [market_coin for market_coin, _ in ranked_markets]
|
|
1822
|
+
market_labels = ", ".join(f"{market_coin}({volume:.0f})" for market_coin, volume in ranked_markets)
|
|
1823
|
+
print(f"[AUTO] Top volume scan set: {market_labels}")
|
|
1824
|
+
if not scan_coins:
|
|
1825
|
+
print("[AUTO-WARN] No perp markets with usable volume data were returned.")
|
|
1826
|
+
await asyncio.sleep(scan_interval)
|
|
1827
|
+
continue
|
|
1828
|
+
|
|
1829
|
+
eligible_scan_coins: List[str] = []
|
|
1830
|
+
current_time_ms = now_ms()
|
|
1831
|
+
for scan_coin in scan_coins:
|
|
1832
|
+
coin_state = get_or_create_coin_session(coin_sessions, scan_coin)
|
|
1833
|
+
reset_coin_session_after_cooldown_if_needed(
|
|
1834
|
+
coin_state,
|
|
1835
|
+
current_time_ms,
|
|
1836
|
+
risk_session_log=risk_session_log,
|
|
1837
|
+
session_pnl=risk_session.realized_pnl,
|
|
1838
|
+
)
|
|
1839
|
+
is_blocked, block_reason = coin_is_blocked_by_risk(coin_state, current_time_ms)
|
|
1840
|
+
if is_blocked:
|
|
1841
|
+
print(f"[AUTO-RISK] Skipping {scan_coin}: {block_reason}")
|
|
1842
|
+
_append_risk_event_log(
|
|
1843
|
+
risk_session_log,
|
|
1844
|
+
{
|
|
1845
|
+
"ts_ms": current_time_ms,
|
|
1846
|
+
"event": "coin_skipped",
|
|
1847
|
+
"coin": coin_state.coin,
|
|
1848
|
+
"reason": coin_state.cooldown_reason,
|
|
1849
|
+
"blocked_detail": block_reason,
|
|
1850
|
+
"coin_pnl": coin_state.realized_pnl,
|
|
1851
|
+
"session_pnl": risk_session.realized_pnl,
|
|
1852
|
+
},
|
|
1853
|
+
)
|
|
1854
|
+
continue
|
|
1855
|
+
eligible_scan_coins.append(scan_coin)
|
|
1856
|
+
|
|
1857
|
+
if not eligible_scan_coins:
|
|
1858
|
+
print("[AUTO-RISK] All scan candidates are blocked by coin session controls.")
|
|
1859
|
+
await asyncio.sleep(scan_interval)
|
|
1860
|
+
continue
|
|
1861
|
+
|
|
1862
|
+
shared_snapshot = await build_auto_scan_loop_snapshot(
|
|
1863
|
+
info=info,
|
|
1864
|
+
account_address=account_address,
|
|
1865
|
+
metrics_start_time_ms=metrics_start_time_ms,
|
|
1866
|
+
)
|
|
1867
|
+
selected_coin: Optional[str] = None
|
|
1868
|
+
selected_decision: Optional[AutoTradeDecision] = None
|
|
1869
|
+
selected_size: Optional[float] = None
|
|
1870
|
+
|
|
1871
|
+
scan_concurrency = min(max_concurrent_scans, max(1, len(eligible_scan_coins)))
|
|
1872
|
+
scan_semaphore = asyncio.Semaphore(scan_concurrency)
|
|
1873
|
+
|
|
1874
|
+
async def _scan_with_limit(scan_coin: str) -> AutoScanCandidate:
|
|
1875
|
+
async with scan_semaphore:
|
|
1876
|
+
return await scan_auto_trade_candidate(
|
|
1877
|
+
info=info,
|
|
1878
|
+
scan_coin=scan_coin,
|
|
1879
|
+
intervals=intervals,
|
|
1880
|
+
periods=periods,
|
|
1881
|
+
min_agreement=min_agreement,
|
|
1882
|
+
adx_threshold=adx_threshold,
|
|
1883
|
+
take_profit_pct=take_profit_pct,
|
|
1884
|
+
stop_loss_pct=stop_loss_pct,
|
|
1885
|
+
min_take_profit_pct=min_take_profit_pct,
|
|
1886
|
+
max_take_profit_pct=max_take_profit_pct,
|
|
1887
|
+
macd_fast=macd_fast,
|
|
1888
|
+
macd_slow=macd_slow,
|
|
1889
|
+
macd_signal_period=macd_signal_period,
|
|
1890
|
+
sar_acceleration=sar_acceleration,
|
|
1891
|
+
sar_maximum=sar_maximum,
|
|
1892
|
+
adx_timeperiod=adx_timeperiod,
|
|
1893
|
+
bb_timeperiod=bb_timeperiod,
|
|
1894
|
+
bb_dev=bb_dev,
|
|
1895
|
+
scalp=scalp,
|
|
1896
|
+
use_last_closed_candle=use_last_closed_candle,
|
|
1897
|
+
use_sar_stop_on_shortest_interval=use_sar_stop_on_shortest_interval,
|
|
1898
|
+
snapshot=shared_snapshot,
|
|
1899
|
+
use_websocket_candles=use_websocket_candles,
|
|
1900
|
+
)
|
|
1901
|
+
|
|
1902
|
+
scan_tasks = [asyncio.create_task(_scan_with_limit(scan_coin)) for scan_coin in eligible_scan_coins]
|
|
1903
|
+
try:
|
|
1904
|
+
for completed_task in asyncio.as_completed(scan_tasks):
|
|
1905
|
+
try:
|
|
1906
|
+
candidate = await completed_task
|
|
1907
|
+
except asyncio.CancelledError:
|
|
1908
|
+
raise
|
|
1909
|
+
except Exception as exc:
|
|
1910
|
+
cp.warning(f"[AUTO-WARN] Market scan task failed: {exc}")
|
|
1911
|
+
continue
|
|
1912
|
+
|
|
1913
|
+
if candidate.existing_position is not None:
|
|
1914
|
+
mid = float(shared_snapshot.mids.get(candidate.coin.upper(), 0.0))
|
|
1915
|
+
upnl = compute_position_unrealized_pnl(candidate.existing_position, mid) if mid > 0.0 else None
|
|
1916
|
+
upnl_str = f"{upnl:.8f}" if upnl is not None else "N/A"
|
|
1917
|
+
print(
|
|
1918
|
+
f"[AUTO] Existing {candidate.coin} position detected; skipping new auto trade on that market. "
|
|
1919
|
+
f"uPnL={upnl_str} {format_auto_scan_metrics(shared_snapshot)}"
|
|
1920
|
+
)
|
|
1921
|
+
continue
|
|
1922
|
+
|
|
1923
|
+
if candidate.decision is None or candidate.decision.direction is None or candidate.decision.take_profit_pct is None:
|
|
1924
|
+
continue
|
|
1925
|
+
|
|
1926
|
+
try:
|
|
1927
|
+
resolved_size, size_reason = await resolve_auto_trade_size(
|
|
1928
|
+
info=info,
|
|
1929
|
+
account_address=account_address,
|
|
1930
|
+
coin=candidate.coin,
|
|
1931
|
+
side=candidate.decision.direction,
|
|
1932
|
+
size=size,
|
|
1933
|
+
size_pct=size_pct_fraction,
|
|
1934
|
+
)
|
|
1935
|
+
except Exception as exc:
|
|
1936
|
+
print(f"[AUTO-WARN] {candidate.coin} size resolution failed; skipping trade candidate: {exc}")
|
|
1937
|
+
continue
|
|
1938
|
+
print(f"[AUTO] {candidate.coin} size resolved to {resolved_size:.8f} ({size_reason})")
|
|
1939
|
+
|
|
1940
|
+
selected_coin = candidate.coin
|
|
1941
|
+
selected_decision = candidate.decision
|
|
1942
|
+
selected_size = resolved_size
|
|
1943
|
+
break
|
|
1944
|
+
except Exception as exc:
|
|
1945
|
+
logger.error(exc)
|
|
1946
|
+
|
|
1947
|
+
for task in scan_tasks:
|
|
1948
|
+
if not task.done():
|
|
1949
|
+
task.cancel()
|
|
1950
|
+
await asyncio.gather(*scan_tasks, return_exceptions=True)
|
|
1951
|
+
|
|
1952
|
+
if selected_coin is None or selected_decision is None or selected_size is None:
|
|
1953
|
+
print('=' * 40 + f'Sleeping {scan_interval} seconds .. ' + '=' * 40)
|
|
1954
|
+
await asyncio.sleep(scan_interval)
|
|
1955
|
+
continue
|
|
1956
|
+
|
|
1957
|
+
if dry_run:
|
|
1958
|
+
print(f"[AUTO] Dry run enabled; {selected_coin} signal will not be traded.")
|
|
1959
|
+
await asyncio.sleep(scan_interval)
|
|
1960
|
+
continue
|
|
1961
|
+
|
|
1962
|
+
direction = selected_decision.direction
|
|
1963
|
+
auto_tp_pct = selected_decision.take_profit_pct
|
|
1964
|
+
auto_sl_pct = stop_loss_pct if stop_loss_pct is not None else selected_decision.stop_loss_pct
|
|
1965
|
+
auto_sl_trigger_px = None
|
|
1966
|
+
if use_sar_stop_on_shortest_interval:
|
|
1967
|
+
auto_sl_trigger_px = selected_decision.stop_loss_trigger_px
|
|
1968
|
+
shortest_snapshot = get_shortest_interval_snapshot(selected_decision.snapshots)
|
|
1969
|
+
sl_display = f"{auto_sl_pct * 100:.4f}%" if auto_sl_pct is not None else "N/A"
|
|
1970
|
+
print(
|
|
1971
|
+
f"[AUTO] Trading {direction.upper()} {selected_coin}: size={selected_size:.8f}, "
|
|
1972
|
+
f"tp_pct={auto_tp_pct * 100:.4f}%, sl_pct={sl_display}, "
|
|
1973
|
+
f"sl_trigger={f'{auto_sl_trigger_px:.8f}' if auto_sl_trigger_px is not None else 'N/A'}"
|
|
1974
|
+
)
|
|
1975
|
+
|
|
1976
|
+
trade_cycle_started_ms = now_ms()
|
|
1977
|
+
await run_bracket_entry(
|
|
1978
|
+
coin=selected_coin,
|
|
1979
|
+
direction=direction,
|
|
1980
|
+
size=selected_size,
|
|
1981
|
+
take_profit_pct=auto_tp_pct,
|
|
1982
|
+
stop_loss_pct=auto_sl_pct,
|
|
1983
|
+
stop_loss_trigger_px=auto_sl_trigger_px,
|
|
1984
|
+
take_profit_levels=take_profit_levels,
|
|
1985
|
+
use_trailing_tp=use_trailing_tp,
|
|
1986
|
+
trailing_tp_trigger_level=trailing_tp_trigger_level,
|
|
1987
|
+
trailing_tp_profit_pct=trailing_tp_profit_pct,
|
|
1988
|
+
entry_retries=entry_retries,
|
|
1989
|
+
entry_repost_interval=entry_repost_interval,
|
|
1990
|
+
poll_interval=poll_interval,
|
|
1991
|
+
tp_reversal_pct=tp_reversal_pct,
|
|
1992
|
+
entry_tif=entry_tif,
|
|
1993
|
+
tp_tif=tp_tif,
|
|
1994
|
+
market_fallback=market_fallback,
|
|
1995
|
+
market_slippage=market_slippage,
|
|
1996
|
+
cancel_existing_tpsl=cancel_existing_tpsl,
|
|
1997
|
+
tp_reversal_limit_exit=tp_reversal_limit_exit,
|
|
1998
|
+
tp_reversal_stop_buffer_pct=tp_reversal_stop_buffer_pct,
|
|
1999
|
+
use_testnet=use_testnet,
|
|
2000
|
+
use_websocket=use_websocket,
|
|
2001
|
+
hide_orders=hide_orders,
|
|
2002
|
+
auto_sar_stop_interval=(
|
|
2003
|
+
shortest_snapshot.interval
|
|
2004
|
+
if use_sar_stop_on_shortest_interval and shortest_snapshot is not None
|
|
2005
|
+
else None
|
|
2006
|
+
),
|
|
2007
|
+
auto_sar_stop_periods=periods if use_sar_stop_on_shortest_interval else None,
|
|
2008
|
+
auto_sar_acceleration=sar_acceleration if use_sar_stop_on_shortest_interval else None,
|
|
2009
|
+
auto_sar_maximum=sar_maximum if use_sar_stop_on_shortest_interval else None,
|
|
2010
|
+
auto_use_last_closed_candle=use_last_closed_candle,
|
|
2011
|
+
auto_use_websocket_candles=use_websocket_candles,
|
|
2012
|
+
account_address=account_address,
|
|
2013
|
+
info=info,
|
|
2014
|
+
exchange=exchange,
|
|
2015
|
+
)
|
|
2016
|
+
trade_cycle_closed_ms = now_ms()
|
|
2017
|
+
completed_trades += 1
|
|
2018
|
+
auto_trades_logger.info(
|
|
2019
|
+
"[AUTO-TRADE-COMPLETE] coin=%s direction=%s size=%.8f tp_pct=%.8f sl_pct=%s completed_trades=%d",
|
|
2020
|
+
selected_coin,
|
|
2021
|
+
direction,
|
|
2022
|
+
selected_size,
|
|
2023
|
+
auto_tp_pct,
|
|
2024
|
+
f"{auto_sl_pct:.8f}" if auto_sl_pct is not None else "N/A",
|
|
2025
|
+
completed_trades,
|
|
2026
|
+
)
|
|
2027
|
+
|
|
2028
|
+
if info is None or exchange is None:
|
|
2029
|
+
account_address, info, exchange = await init_clients(use_testnet, use_websocket=use_websocket)
|
|
2030
|
+
|
|
2031
|
+
cycle_pnl, cycle_fills, pnl_reason = await fetch_trade_cycle_net_pnl(
|
|
2032
|
+
info=info,
|
|
2033
|
+
account_address=account_address,
|
|
2034
|
+
coin=selected_coin,
|
|
2035
|
+
start_time_ms=trade_cycle_started_ms,
|
|
2036
|
+
end_time_ms=trade_cycle_closed_ms,
|
|
2037
|
+
)
|
|
2038
|
+
if pnl_reason:
|
|
2039
|
+
print(
|
|
2040
|
+
f"[AUTO-WARN] {selected_coin} post-trade PnL unavailable; "
|
|
2041
|
+
f"risk session not updated for this cycle: {pnl_reason}"
|
|
2042
|
+
)
|
|
2043
|
+
_append_risk_event_log(
|
|
2044
|
+
risk_session_log,
|
|
2045
|
+
{
|
|
2046
|
+
"ts_ms": trade_cycle_closed_ms,
|
|
2047
|
+
"event": "cycle_pnl_unavailable",
|
|
2048
|
+
"coin": selected_coin,
|
|
2049
|
+
"reason": pnl_reason,
|
|
2050
|
+
"session_pnl": risk_session.realized_pnl,
|
|
2051
|
+
},
|
|
2052
|
+
)
|
|
2053
|
+
else:
|
|
2054
|
+
coin_state = get_or_create_coin_session(coin_sessions, selected_coin)
|
|
2055
|
+
coin_reason, session_reason = update_risk_after_trade_cycle(
|
|
2056
|
+
coin_state=coin_state,
|
|
2057
|
+
session_state=risk_session,
|
|
2058
|
+
cycle_pnl=cycle_pnl,
|
|
2059
|
+
cycle_started_ms=trade_cycle_started_ms,
|
|
2060
|
+
cycle_closed_ms=trade_cycle_closed_ms,
|
|
2061
|
+
max_coin_trades_per_session=max_coin_trades_per_session,
|
|
2062
|
+
coin_session_cooldown_seconds=coin_session_cooldown_seconds,
|
|
2063
|
+
coin_session_profit_target=coin_session_profit_target,
|
|
2064
|
+
coin_session_min_profit_to_lock=coin_session_min_profit_to_lock,
|
|
2065
|
+
coin_session_giveback_pct=coin_session_giveback_pct,
|
|
2066
|
+
cooldown_after_loss_following_wins=cooldown_after_loss_following_wins,
|
|
2067
|
+
session_profit_target=session_profit_target,
|
|
2068
|
+
session_max_loss=session_max_loss,
|
|
2069
|
+
session_giveback_pct=session_giveback_pct,
|
|
2070
|
+
)
|
|
2071
|
+
print(
|
|
2072
|
+
f"[AUTO-RISK] {selected_coin} cycle_pnl={cycle_pnl:.6f} "
|
|
2073
|
+
f"coin_pnl={coin_state.realized_pnl:.6f} coin_peak={coin_state.peak_pnl:.6f} "
|
|
2074
|
+
f"coin_cycles={coin_state.completed_cycles} session_pnl={risk_session.realized_pnl:.6f} "
|
|
2075
|
+
f"session_peak={risk_session.peak_pnl:.6f}"
|
|
2076
|
+
)
|
|
2077
|
+
_append_risk_event_log(
|
|
2078
|
+
risk_session_log,
|
|
2079
|
+
{
|
|
2080
|
+
"ts_ms": trade_cycle_closed_ms,
|
|
2081
|
+
"event": "cycle_complete",
|
|
2082
|
+
"coin": selected_coin,
|
|
2083
|
+
"cycle_pnl": cycle_pnl,
|
|
2084
|
+
"coin_pnl": coin_state.realized_pnl,
|
|
2085
|
+
"coin_peak_pnl": coin_state.peak_pnl,
|
|
2086
|
+
"coin_cycles": coin_state.completed_cycles,
|
|
2087
|
+
"session_pnl": risk_session.realized_pnl,
|
|
2088
|
+
"session_peak_pnl": risk_session.peak_pnl,
|
|
2089
|
+
"fills_count": len(cycle_fills),
|
|
2090
|
+
},
|
|
2091
|
+
)
|
|
2092
|
+
if coin_reason is not None:
|
|
2093
|
+
print(
|
|
2094
|
+
f"[AUTO-RISK] {selected_coin} entering cooldown for "
|
|
2095
|
+
f"{coin_session_cooldown_seconds:.1f}s reason=\"{coin_reason}\""
|
|
2096
|
+
)
|
|
2097
|
+
_append_risk_event_log(
|
|
2098
|
+
risk_session_log,
|
|
2099
|
+
{
|
|
2100
|
+
"ts_ms": trade_cycle_closed_ms,
|
|
2101
|
+
"event": "coin_cooldown",
|
|
2102
|
+
"coin": selected_coin,
|
|
2103
|
+
"reason": coin_reason,
|
|
2104
|
+
"coin_pnl": coin_state.realized_pnl,
|
|
2105
|
+
"session_pnl": risk_session.realized_pnl,
|
|
2106
|
+
},
|
|
2107
|
+
)
|
|
2108
|
+
if session_reason is not None:
|
|
2109
|
+
print(f"[AUTO-RISK] Session stop triggered: {session_reason}")
|
|
2110
|
+
_append_risk_event_log(
|
|
2111
|
+
risk_session_log,
|
|
2112
|
+
{
|
|
2113
|
+
"ts_ms": trade_cycle_closed_ms,
|
|
2114
|
+
"event": "session_stop",
|
|
2115
|
+
"coin": selected_coin,
|
|
2116
|
+
"reason": session_reason,
|
|
2117
|
+
"coin_pnl": coin_state.realized_pnl,
|
|
2118
|
+
"session_pnl": risk_session.realized_pnl,
|
|
2119
|
+
},
|
|
2120
|
+
)
|
|
2121
|
+
if risk_session.stopped:
|
|
2122
|
+
return
|
|
2123
|
+
|
|
2124
|
+
if 0 < max_trades <= completed_trades:
|
|
2125
|
+
print(f"[AUTO] Completed {completed_trades} trade(s); exiting auto mode.")
|
|
2126
|
+
return
|
|
2127
|
+
|
|
2128
|
+
if not loop_after_trade:
|
|
2129
|
+
print(f"[AUTO] Completed {completed_trades} trade(s); exiting because auto looping is disabled.")
|
|
2130
|
+
return
|
|
2131
|
+
|
|
2132
|
+
if cooldown_after_trade > 0.0:
|
|
2133
|
+
print(f"[AUTO] Cooling down for {cooldown_after_trade:.2f}s before resuming scans.")
|
|
2134
|
+
await asyncio.sleep(cooldown_after_trade)
|
|
2135
|
+
|
|
2136
|
+
metrics_start_time_ms = int(time.time() * 1000)
|
|
2137
|
+
except KeyboardInterrupt:
|
|
2138
|
+
print("\n[!] Caught Ctrl+C, stopping auto trader.")
|
|
2139
|
+
finally:
|
|
2140
|
+
if owns_clients:
|
|
2141
|
+
await close_clients(info, exchange)
|