binance-quant-engine 0.1.1__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.
- binance_quant_engine/__init__.py +3 -0
- binance_quant_engine/backtest/__init__.py +1 -0
- binance_quant_engine/backtest/vectorized.py +177 -0
- binance_quant_engine/data/__init__.py +1 -0
- binance_quant_engine/data/cache.py +540 -0
- binance_quant_engine/data/klines.py +43 -0
- binance_quant_engine/execution/__init__.py +1 -0
- binance_quant_engine/execution/algo_api.py +84 -0
- binance_quant_engine/execution/brackets.py +973 -0
- binance_quant_engine/execution/host.py +65 -0
- binance_quant_engine/execution/utils.py +46 -0
- binance_quant_engine/mcp/__init__.py +1 -0
- binance_quant_engine/mcp/server.py +115 -0
- binance_quant_engine/strategy/__init__.py +1 -0
- binance_quant_engine/strategy/demo_squeeze.py +183 -0
- binance_quant_engine/strategy/protocol.py +95 -0
- binance_quant_engine-0.1.1.dist-info/METADATA +159 -0
- binance_quant_engine-0.1.1.dist-info/RECORD +21 -0
- binance_quant_engine-0.1.1.dist-info/WHEEL +4 -0
- binance_quant_engine-0.1.1.dist-info/entry_points.txt +3 -0
- binance_quant_engine-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
"""Server-side bracket order management (Algo Order API).
|
|
2
|
+
|
|
3
|
+
Mixin class providing methods for placing, cancelling, and checking
|
|
4
|
+
server-side stop-loss and take-profit orders via Binance Algo API.
|
|
5
|
+
Also includes runner SL ratcheting for PBT Phase 2.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from binance_quant_engine.execution.host import ScalperProtocol
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
from binance.exceptions import BinanceAPIException
|
|
20
|
+
|
|
21
|
+
from binance_quant_engine.execution.utils import (
|
|
22
|
+
ALGO_ORDER_DEAD_STATUSES,
|
|
23
|
+
round_to_tick,
|
|
24
|
+
to_api_symbol,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("Scalper")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BracketMixin:
|
|
31
|
+
"""Mixin: Server-side bracket orders (SL/TP) via Algo Order API."""
|
|
32
|
+
|
|
33
|
+
def _place_algo_order(
|
|
34
|
+
self: "ScalperProtocol",
|
|
35
|
+
symbol: str,
|
|
36
|
+
side: str,
|
|
37
|
+
order_type: str,
|
|
38
|
+
quantity: float,
|
|
39
|
+
trigger_price: float,
|
|
40
|
+
limit_price: float | None = None,
|
|
41
|
+
label: str = "order",
|
|
42
|
+
position_side: str | None = None,
|
|
43
|
+
) -> str | None:
|
|
44
|
+
"""Place a conditional order via Binance Algo Order API.
|
|
45
|
+
|
|
46
|
+
Binance migrated STOP/STOP_MARKET/TAKE_PROFIT/TAKE_PROFIT_MARKET
|
|
47
|
+
to POST /fapi/v1/algoOrder with algoType=CONDITIONAL.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
symbol: Trading symbol.
|
|
51
|
+
side: 'BUY' or 'SELL'.
|
|
52
|
+
order_type: 'STOP', 'STOP_MARKET', 'TAKE_PROFIT', etc.
|
|
53
|
+
quantity: Position quantity.
|
|
54
|
+
trigger_price: Price at which the order triggers.
|
|
55
|
+
limit_price: Limit price for STOP/TAKE_PROFIT. None for MARKET types.
|
|
56
|
+
label: Description for logging.
|
|
57
|
+
position_side: Hedge Mode position side ('LONG'/'SHORT').
|
|
58
|
+
When set, positionSide is sent instead of reduceOnly.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
algoId string, or None on failure.
|
|
62
|
+
"""
|
|
63
|
+
if self.paper_mode:
|
|
64
|
+
return f"paper_{label}_{int(time.time() * 1000)}"
|
|
65
|
+
|
|
66
|
+
params: dict = {
|
|
67
|
+
"algoType": "CONDITIONAL",
|
|
68
|
+
"symbol": symbol,
|
|
69
|
+
"side": side,
|
|
70
|
+
"type": order_type,
|
|
71
|
+
"quantity": str(quantity),
|
|
72
|
+
"triggerPrice": str(trigger_price),
|
|
73
|
+
}
|
|
74
|
+
# Hedge Mode: positionSide is mutually exclusive with reduceOnly
|
|
75
|
+
if position_side is not None:
|
|
76
|
+
params["positionSide"] = position_side
|
|
77
|
+
else:
|
|
78
|
+
params["reduceOnly"] = "true"
|
|
79
|
+
|
|
80
|
+
if limit_price is not None:
|
|
81
|
+
params["price"] = str(limit_price)
|
|
82
|
+
params["timeInForce"] = "GTC"
|
|
83
|
+
|
|
84
|
+
lp_str = f"${limit_price:.6f}" if limit_price is not None else "N/A"
|
|
85
|
+
logger.debug(
|
|
86
|
+
f"[{symbol}] Algo order request: {label} "
|
|
87
|
+
f"side={side} type={order_type} qty={quantity:.6f} "
|
|
88
|
+
f"trigger=${trigger_price:.6f} limit={lp_str}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
resp = self._algo_api.place_order(params)
|
|
93
|
+
algo_id = str(resp["algoId"])
|
|
94
|
+
logger.info(
|
|
95
|
+
f"[{symbol}] SERVER {label.upper()} placed via Algo API: "
|
|
96
|
+
f"{side} {quantity:.6f} @ trigger=${trigger_price:.6f} "
|
|
97
|
+
f"limit={lp_str} (algoId={algo_id})"
|
|
98
|
+
)
|
|
99
|
+
return algo_id
|
|
100
|
+
except Exception as e:
|
|
101
|
+
err_str = str(e)
|
|
102
|
+
logger.error(f"[{symbol}] SERVER {label.upper()} FAILED: {e}. params={params}")
|
|
103
|
+
# -2021: "Order would immediately trigger" — price already past
|
|
104
|
+
# the stop level. Return sentinel so caller can force-close.
|
|
105
|
+
if "-2021" in err_str:
|
|
106
|
+
return "WOULD_TRIGGER"
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def _place_server_stop_loss(
|
|
110
|
+
self: "ScalperProtocol",
|
|
111
|
+
symbol: str,
|
|
112
|
+
signal: int,
|
|
113
|
+
quantity: float,
|
|
114
|
+
entry_price: float,
|
|
115
|
+
move_size: float,
|
|
116
|
+
stop_mult: float,
|
|
117
|
+
position_side: str | None = None,
|
|
118
|
+
) -> str | None:
|
|
119
|
+
"""Place a server-side stop-loss order on Binance as a safety net.
|
|
120
|
+
|
|
121
|
+
This ensures positions are protected even if the client process crashes.
|
|
122
|
+
The stop price is set at the strategy's stop-loss level (move_size * stop_mult)
|
|
123
|
+
with a 0.5% buffer beyond to account for slippage in fast markets.
|
|
124
|
+
|
|
125
|
+
Uses Binance Algo Order API (POST /fapi/v1/algoOrder, algoType=CONDITIONAL).
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
symbol: Trading symbol.
|
|
129
|
+
signal: +1 (long) or -1 (short).
|
|
130
|
+
quantity: Position quantity.
|
|
131
|
+
entry_price: Fill price.
|
|
132
|
+
move_size: Magnitude of the triggering move.
|
|
133
|
+
stop_mult: Stop loss multiplier.
|
|
134
|
+
position_side: Hedge Mode ('LONG'/'SHORT'). None = One-Way.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
algoId string, or None on failure (position still tracked).
|
|
138
|
+
"""
|
|
139
|
+
if self.paper_mode:
|
|
140
|
+
return f"paper_sl_{int(time.time() * 1000)}"
|
|
141
|
+
|
|
142
|
+
# C1: Guard — stop_mult must be positive (negative = inverted SL direction)
|
|
143
|
+
if stop_mult < 0:
|
|
144
|
+
logger.error(
|
|
145
|
+
f"[{symbol}] SL INVARIANT: stop_mult={stop_mult:.4f} < 0 — would place SL in wrong direction. Aborting."
|
|
146
|
+
)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
# Stop price: entry ± (move_size * stop_mult) with 0.5% buffer
|
|
150
|
+
stop_distance = move_size * stop_mult
|
|
151
|
+
buffer = 0.005 # 0.5% extra beyond stop to ensure fill
|
|
152
|
+
|
|
153
|
+
if signal == 1: # Long: stop below entry
|
|
154
|
+
stop_price = entry_price * (1.0 - stop_distance)
|
|
155
|
+
limit_price = stop_price * (1.0 - buffer) # Sell limit below stop
|
|
156
|
+
close_side = "SELL"
|
|
157
|
+
else: # Short: stop above entry
|
|
158
|
+
stop_price = entry_price * (1.0 + stop_distance)
|
|
159
|
+
limit_price = stop_price * (1.0 + buffer) # Buy limit above stop
|
|
160
|
+
close_side = "BUY"
|
|
161
|
+
|
|
162
|
+
info = self.get_symbol_info(symbol)
|
|
163
|
+
tick_size = info.get("tick_size", 0.01)
|
|
164
|
+
price_precision = info.get("price_precision", 2)
|
|
165
|
+
|
|
166
|
+
# Round prices to tick size
|
|
167
|
+
stop_price = round_to_tick(stop_price, tick_size, price_precision)
|
|
168
|
+
limit_price = round_to_tick(limit_price, tick_size, price_precision)
|
|
169
|
+
|
|
170
|
+
# T2-1 runtime guard: sanity check stop price placement
|
|
171
|
+
if entry_price <= 0:
|
|
172
|
+
logger.error(f"[{symbol}] SL INVARIANT: entry_price={entry_price!r} — aborting SL placement")
|
|
173
|
+
return None
|
|
174
|
+
if stop_price <= 0:
|
|
175
|
+
logger.error(f"[{symbol}] SL INVARIANT: stop_price={stop_price!r} — aborting SL placement")
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
dist_pct = stop_distance * 100
|
|
179
|
+
logger.info(
|
|
180
|
+
f"[{symbol}] SL calc: entry=${entry_price:.4f} "
|
|
181
|
+
f"move={move_size * 100:.2f}% × stop_mult={stop_mult:.4f} "
|
|
182
|
+
f"→ dist={dist_pct:.2f}% trigger=${stop_price:.4f} "
|
|
183
|
+
f"limit=${limit_price:.4f} side={close_side}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
algo_id = self._place_algo_order(
|
|
187
|
+
symbol=symbol,
|
|
188
|
+
side=close_side,
|
|
189
|
+
order_type="STOP",
|
|
190
|
+
quantity=quantity,
|
|
191
|
+
trigger_price=stop_price,
|
|
192
|
+
limit_price=limit_price,
|
|
193
|
+
label="stop-loss",
|
|
194
|
+
position_side=position_side,
|
|
195
|
+
)
|
|
196
|
+
if algo_id == "WOULD_TRIGGER":
|
|
197
|
+
logger.error(
|
|
198
|
+
f"[{symbol}] SL WOULD IMMEDIATELY TRIGGER — "
|
|
199
|
+
f"price already past stop level ${stop_price:.4f}. "
|
|
200
|
+
f"Returning sentinel for force-close."
|
|
201
|
+
)
|
|
202
|
+
self.discord.send(
|
|
203
|
+
content=(
|
|
204
|
+
f"\U0001f6a8 **SL 즉시체결** [{symbol}] — "
|
|
205
|
+
f"현재가가 SL ${stop_price:.4f}을 이미 이탈. "
|
|
206
|
+
f"강제 청산 필요."
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
return "WOULD_TRIGGER"
|
|
210
|
+
if algo_id is None:
|
|
211
|
+
logger.error(f"[{symbol}] Position is UNPROTECTED — client-side stop still active.")
|
|
212
|
+
self.discord.send(
|
|
213
|
+
content=(
|
|
214
|
+
f"\u26a0\ufe0f **서버 SL 실패** [{symbol}] — "
|
|
215
|
+
f"거래소측 보호주문 없음 (클라이언트 SL만 작동). "
|
|
216
|
+
f"다음 사이클에 재시도."
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
return algo_id
|
|
220
|
+
|
|
221
|
+
def _place_server_take_profit(
|
|
222
|
+
self: "ScalperProtocol",
|
|
223
|
+
symbol: str,
|
|
224
|
+
signal: int,
|
|
225
|
+
quantity: float,
|
|
226
|
+
entry_price: float,
|
|
227
|
+
move_size: float,
|
|
228
|
+
target_retrace: float,
|
|
229
|
+
position_side: str | None = None,
|
|
230
|
+
) -> str | None:
|
|
231
|
+
"""Place a server-side take-profit order on Binance.
|
|
232
|
+
|
|
233
|
+
This captures intra-bar TP moves that would otherwise be missed
|
|
234
|
+
by the 15m polling cycle. Combined with the server stop-loss,
|
|
235
|
+
this creates an OCO-like bracket around the position.
|
|
236
|
+
|
|
237
|
+
Uses Binance Algo Order API (POST /fapi/v1/algoOrder, algoType=CONDITIONAL).
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
symbol: Trading symbol.
|
|
241
|
+
signal: +1 (long) or -1 (short).
|
|
242
|
+
quantity: Position quantity.
|
|
243
|
+
entry_price: Fill price.
|
|
244
|
+
move_size: Magnitude of the triggering move.
|
|
245
|
+
target_retrace: Fraction of move to target (e.g. 0.382).
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
algoId string, or None on failure.
|
|
249
|
+
"""
|
|
250
|
+
if self.paper_mode:
|
|
251
|
+
return f"paper_tp_{int(time.time() * 1000)}"
|
|
252
|
+
|
|
253
|
+
# D1/D4: Guard — target_retrace must be positive (zero/negative = inverted or no TP)
|
|
254
|
+
# and move_size × target_retrace must not exceed 100% (SHORT trigger_price would go ≤ 0)
|
|
255
|
+
if target_retrace <= 0:
|
|
256
|
+
logger.error(
|
|
257
|
+
f"[{symbol}] TP INVARIANT: target_retrace={target_retrace:.4f} ≤ 0 — would invert or zero TP. Aborting."
|
|
258
|
+
)
|
|
259
|
+
return None
|
|
260
|
+
if move_size * target_retrace >= 1.0:
|
|
261
|
+
logger.error(
|
|
262
|
+
f"[{symbol}] TP INVARIANT: move_size={move_size:.4f} × target_retrace={target_retrace:.4f} "
|
|
263
|
+
f"= {move_size * target_retrace:.4f} ≥ 1.0 — SHORT trigger_price would be ≤ 0. Aborting."
|
|
264
|
+
)
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
# TP price: entry ± (move_size * target_retrace)
|
|
268
|
+
target_distance = move_size * target_retrace
|
|
269
|
+
buffer = 0.002 # 0.2% inside target to ensure fill
|
|
270
|
+
|
|
271
|
+
if signal == 1: # Long: TP above entry
|
|
272
|
+
trigger_price = entry_price * (1.0 + target_distance)
|
|
273
|
+
limit_price = trigger_price * (1.0 - buffer)
|
|
274
|
+
close_side = "SELL"
|
|
275
|
+
else: # Short: TP below entry
|
|
276
|
+
trigger_price = entry_price * (1.0 - target_distance)
|
|
277
|
+
limit_price = trigger_price * (1.0 + buffer)
|
|
278
|
+
close_side = "BUY"
|
|
279
|
+
|
|
280
|
+
info = self.get_symbol_info(symbol)
|
|
281
|
+
tick_size = info.get("tick_size", 0.01)
|
|
282
|
+
price_precision = info.get("price_precision", 2)
|
|
283
|
+
|
|
284
|
+
# Round prices to tick size
|
|
285
|
+
trigger_price = round_to_tick(trigger_price, tick_size, price_precision)
|
|
286
|
+
limit_price = round_to_tick(limit_price, tick_size, price_precision)
|
|
287
|
+
|
|
288
|
+
# T2-1 runtime guard: sanity check TP price placement
|
|
289
|
+
if entry_price <= 0:
|
|
290
|
+
logger.error(f"[{symbol}] TP INVARIANT: entry_price={entry_price!r} — aborting TP placement")
|
|
291
|
+
return None
|
|
292
|
+
if trigger_price <= 0:
|
|
293
|
+
logger.error(f"[{symbol}] TP INVARIANT: trigger_price={trigger_price!r} — aborting TP placement")
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
dist_pct = target_distance * 100
|
|
297
|
+
logger.info(
|
|
298
|
+
f"[{symbol}] TP calc: entry=${entry_price:.4f} "
|
|
299
|
+
f"move={move_size * 100:.2f}% × retrace={target_retrace:.3f} "
|
|
300
|
+
f"→ dist={dist_pct:.2f}% trigger=${trigger_price:.4f} "
|
|
301
|
+
f"limit=${limit_price:.4f} side={close_side}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
algo_id = self._place_algo_order(
|
|
305
|
+
symbol=symbol,
|
|
306
|
+
side=close_side,
|
|
307
|
+
order_type="TAKE_PROFIT",
|
|
308
|
+
quantity=quantity,
|
|
309
|
+
trigger_price=trigger_price,
|
|
310
|
+
limit_price=limit_price,
|
|
311
|
+
label="take-profit",
|
|
312
|
+
position_side=position_side,
|
|
313
|
+
)
|
|
314
|
+
if algo_id is None:
|
|
315
|
+
logger.warning(f"[{symbol}] Server TP failed — will rely on client-side TP check.")
|
|
316
|
+
return algo_id
|
|
317
|
+
|
|
318
|
+
def _cancel_server_order(self: "ScalperProtocol", symbol: str, order_id: str, label: str = "order") -> bool:
|
|
319
|
+
"""Cancel a server-side algo order (stop-loss or take-profit).
|
|
320
|
+
|
|
321
|
+
Uses Binance Algo Order API (DELETE /fapi/v1/algoOrder).
|
|
322
|
+
Must be called BEFORE placing a close order to avoid the server
|
|
323
|
+
order triggering while we're trying to close.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
symbol: Trading symbol.
|
|
327
|
+
order_id: The algoId to cancel.
|
|
328
|
+
label: Description for logging (e.g. 'stop-loss', 'take-profit').
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
True on successful cancel or benign errors (already cancelled/filled/expired).
|
|
332
|
+
False on unexpected API failures.
|
|
333
|
+
"""
|
|
334
|
+
if self.paper_mode or not order_id or order_id.startswith("paper_"):
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
resp = self._algo_api.cancel_order(int(order_id))
|
|
339
|
+
logger.info(f"[{symbol}] Server {label} cancelled: algoId={order_id} resp={resp}")
|
|
340
|
+
return True
|
|
341
|
+
except BinanceAPIException as e:
|
|
342
|
+
err_str = str(e)
|
|
343
|
+
# -2011: Unknown order, -25029: algo order not found, -4000: invalid order id
|
|
344
|
+
# -20012: order already closed — all are benign (order is already gone)
|
|
345
|
+
if (
|
|
346
|
+
"Unknown" in err_str
|
|
347
|
+
or "-2011" in err_str
|
|
348
|
+
or "-25029" in err_str
|
|
349
|
+
or "-4000" in err_str
|
|
350
|
+
or "-20012" in err_str
|
|
351
|
+
):
|
|
352
|
+
# Already cancelled, filled, or expired — OK
|
|
353
|
+
logger.debug(f"[{symbol}] Server {label} already gone: algoId={order_id} ({e})")
|
|
354
|
+
return True
|
|
355
|
+
logger.warning(f"[{symbol}] Failed to cancel server {label}: algoId={order_id} error={e}")
|
|
356
|
+
return False
|
|
357
|
+
except Exception as e:
|
|
358
|
+
logger.warning(f"[{symbol}] Unexpected error cancelling server {label}: algoId={order_id} error={e}")
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
def _cancel_server_stop_loss(self: "ScalperProtocol", symbol: str, order_id: str) -> bool:
|
|
362
|
+
"""Cancel the server-side stop-loss.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
True on success or benign error, False on unexpected failure.
|
|
366
|
+
"""
|
|
367
|
+
return self._cancel_server_order(symbol, order_id, "stop-loss")
|
|
368
|
+
|
|
369
|
+
def _cancel_server_take_profit(self: "ScalperProtocol", symbol: str, order_id: str) -> bool:
|
|
370
|
+
"""Cancel the server-side take-profit.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
True on success or benign error, False on unexpected failure.
|
|
374
|
+
"""
|
|
375
|
+
return self._cancel_server_order(symbol, order_id, "take-profit")
|
|
376
|
+
|
|
377
|
+
# ── Server-Side Trailing Stop (TRAILING_STOP_MARKET) ──────────────────
|
|
378
|
+
# r90 migration: replaces dead client-side trail + ratchet with Binance's
|
|
379
|
+
# native tick-by-tick trailing stop. Uses Algo Order API with
|
|
380
|
+
# activationPrice = entry_price (already reached → no -2021 rejection).
|
|
381
|
+
|
|
382
|
+
def _get_trail_params(
|
|
383
|
+
self: "ScalperProtocol",
|
|
384
|
+
signal: int,
|
|
385
|
+
entry_price: float,
|
|
386
|
+
) -> tuple[float, float, str]:
|
|
387
|
+
"""Calculate TRAILING_STOP_MARKET parameters from config.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
signal: +1 (LONG) or -1 (SHORT).
|
|
391
|
+
entry_price: Position entry price.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
(callback_rate, activation_price, close_side) tuple.
|
|
395
|
+
callback_rate: Binance callbackRate (0.1-5.0, percentage).
|
|
396
|
+
activation_price: entry × (1 + trail_activate) for LONG,
|
|
397
|
+
entry × (1 - trail_activate) for SHORT.
|
|
398
|
+
close_side: 'SELL' for LONG, 'BUY' for SHORT.
|
|
399
|
+
"""
|
|
400
|
+
td = self.trail_config.trail_distance
|
|
401
|
+
ta = self.trail_config.trail_activate
|
|
402
|
+
|
|
403
|
+
# trail_distance fraction → Binance callbackRate percentage
|
|
404
|
+
# e.g. 0.005 (0.5%) → 0.5, clamped to [0.5, 5.0] (rule #8: callbackRate >= 0.5%)
|
|
405
|
+
callback_rate = max(0.5, min(5.0, td * 100))
|
|
406
|
+
|
|
407
|
+
# activationPrice = entry × (1 ± trail_activate).
|
|
408
|
+
# Trail stays in NEW status until price reaches this level,
|
|
409
|
+
# then activates and starts tracking the peak.
|
|
410
|
+
# API accepts future prices (verified via live test 2026-03-19).
|
|
411
|
+
if signal == 1: # LONG: activate when price goes UP
|
|
412
|
+
activation_price = entry_price * (1 + ta)
|
|
413
|
+
else: # SHORT: activate when price goes DOWN
|
|
414
|
+
activation_price = entry_price * (1 - ta)
|
|
415
|
+
|
|
416
|
+
close_side = "SELL" if signal == 1 else "BUY"
|
|
417
|
+
|
|
418
|
+
return callback_rate, activation_price, close_side
|
|
419
|
+
|
|
420
|
+
def _place_trailing_stop_market(
|
|
421
|
+
self: "ScalperProtocol",
|
|
422
|
+
symbol: str,
|
|
423
|
+
side: str,
|
|
424
|
+
quantity: float,
|
|
425
|
+
callback_rate: float,
|
|
426
|
+
activation_price: float,
|
|
427
|
+
position_side: str | None = None,
|
|
428
|
+
) -> str | None:
|
|
429
|
+
"""Place TRAILING_STOP_MARKET via Algo Order API (POST /fapi/v1/algoOrder).
|
|
430
|
+
|
|
431
|
+
The regular futures API rejects TRAILING_STOP_MARKET with -4120.
|
|
432
|
+
Algo API is the only supported endpoint. With activationPrice = entry_price
|
|
433
|
+
(already reached), -2021 rejection is avoided.
|
|
434
|
+
"""
|
|
435
|
+
if self.paper_mode:
|
|
436
|
+
return f"paper_trail_{int(time.time() * 1000)}"
|
|
437
|
+
|
|
438
|
+
# Binance rejects a malformed-precision activationPrice outright, so
|
|
439
|
+
# snap it to the symbol's tick grid before it ever leaves this
|
|
440
|
+
# function — the same fix as the stop/take-profit prices above.
|
|
441
|
+
info = self.get_symbol_info(symbol)
|
|
442
|
+
tick_size = info.get("tick_size", 0.01)
|
|
443
|
+
price_precision = info.get("price_precision", 2)
|
|
444
|
+
activation_price = round_to_tick(activation_price, tick_size, price_precision)
|
|
445
|
+
|
|
446
|
+
params: dict = {
|
|
447
|
+
"algoType": "CONDITIONAL",
|
|
448
|
+
"symbol": symbol,
|
|
449
|
+
"side": side,
|
|
450
|
+
"type": "TRAILING_STOP_MARKET",
|
|
451
|
+
"quantity": str(quantity),
|
|
452
|
+
"callbackRate": str(callback_rate),
|
|
453
|
+
"activationPrice": str(activation_price),
|
|
454
|
+
}
|
|
455
|
+
# Hedge Mode: positionSide is mutually exclusive with reduceOnly
|
|
456
|
+
if position_side is not None:
|
|
457
|
+
params["positionSide"] = position_side
|
|
458
|
+
else:
|
|
459
|
+
params["reduceOnly"] = "true"
|
|
460
|
+
|
|
461
|
+
logger.debug(
|
|
462
|
+
f"[{symbol}] Trail order request: side={side} qty={quantity:.6f} "
|
|
463
|
+
f"callbackRate={callback_rate}% activationPrice=${activation_price:.4f}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
resp = self._algo_api.place_order(params)
|
|
468
|
+
algo_id = str(resp["algoId"])
|
|
469
|
+
logger.info(
|
|
470
|
+
f"[{symbol}] SERVER TRAIL placed via Algo API: "
|
|
471
|
+
f"{side} {quantity:.6f} callbackRate={callback_rate}% "
|
|
472
|
+
f"activationPrice=${activation_price:.4f} (algoId={algo_id})"
|
|
473
|
+
)
|
|
474
|
+
return algo_id
|
|
475
|
+
except Exception as e:
|
|
476
|
+
err_str = str(e)
|
|
477
|
+
# -2021: Order would immediately trigger — mark price is too far
|
|
478
|
+
# from activationPrice for Binance to accept the order.
|
|
479
|
+
# Return sentinel so caller can suppress retry spam.
|
|
480
|
+
if "-2021" in err_str:
|
|
481
|
+
logger.info(
|
|
482
|
+
f"[{symbol}] Trail WOULD_TRIGGER — mark too far from "
|
|
483
|
+
f"activationPrice=${activation_price:.6f} (callbackRate={callback_rate}%)"
|
|
484
|
+
)
|
|
485
|
+
return "WOULD_TRIGGER"
|
|
486
|
+
logger.error(f"[{symbol}] SERVER TRAIL FAILED: {e}. params={params}")
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
def _cancel_trailing_stop_market(
|
|
490
|
+
self: "ScalperProtocol",
|
|
491
|
+
symbol: str,
|
|
492
|
+
order_id: str,
|
|
493
|
+
) -> bool:
|
|
494
|
+
"""Cancel TRAILING_STOP_MARKET via Algo Order API (DELETE /fapi/v1/algoOrder)."""
|
|
495
|
+
return self._cancel_server_order(symbol, order_id, "trailing-stop")
|
|
496
|
+
|
|
497
|
+
def _check_trail_order_status(
|
|
498
|
+
self: "ScalperProtocol",
|
|
499
|
+
symbol: str,
|
|
500
|
+
order_id: str,
|
|
501
|
+
) -> dict | None:
|
|
502
|
+
"""Check TRAILING_STOP_MARKET via Algo Order API (GET /fapi/v1/algoOrder)."""
|
|
503
|
+
return self._check_order_filled(symbol, order_id)
|
|
504
|
+
|
|
505
|
+
# ── Trail API Facade ────────────────────────────────────────────────
|
|
506
|
+
# These methods are called by positions.py / reconciliation.py.
|
|
507
|
+
# Internally they use the Algo Order API — the regular futures API
|
|
508
|
+
# rejects TRAILING_STOP_MARKET with -4120.
|
|
509
|
+
|
|
510
|
+
def _place_trail_regular(
|
|
511
|
+
self: "ScalperProtocol",
|
|
512
|
+
symbol: str,
|
|
513
|
+
side: str,
|
|
514
|
+
quantity: float,
|
|
515
|
+
callback_rate: float,
|
|
516
|
+
activation_price: float | None = None,
|
|
517
|
+
position_side: str | None = None,
|
|
518
|
+
) -> str | None:
|
|
519
|
+
"""Place TRAILING_STOP_MARKET order.
|
|
520
|
+
|
|
521
|
+
Delegates to Algo Order API (``_place_trailing_stop_market``).
|
|
522
|
+
Despite the method name, the regular API (POST /fapi/v1/order) returns
|
|
523
|
+
-4120 for this order type — only the Algo API works.
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
algoId as string on success, None on failure.
|
|
527
|
+
"""
|
|
528
|
+
if activation_price is None:
|
|
529
|
+
activation_price = 0.0 # shouldn't happen — caller always provides
|
|
530
|
+
return self._place_trailing_stop_market(
|
|
531
|
+
symbol=symbol,
|
|
532
|
+
side=side,
|
|
533
|
+
quantity=quantity,
|
|
534
|
+
callback_rate=callback_rate,
|
|
535
|
+
activation_price=activation_price,
|
|
536
|
+
position_side=position_side,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
def _cancel_trail_regular(
|
|
540
|
+
self: "ScalperProtocol",
|
|
541
|
+
symbol: str,
|
|
542
|
+
order_id: str,
|
|
543
|
+
) -> bool:
|
|
544
|
+
"""Cancel a TRAILING_STOP_MARKET order.
|
|
545
|
+
|
|
546
|
+
Delegates to Algo Order API (``_cancel_trailing_stop_market``).
|
|
547
|
+
"""
|
|
548
|
+
return self._cancel_trailing_stop_market(symbol, order_id)
|
|
549
|
+
|
|
550
|
+
def _check_trail_regular(
|
|
551
|
+
self: "ScalperProtocol",
|
|
552
|
+
symbol: str,
|
|
553
|
+
order_id: str,
|
|
554
|
+
) -> dict | None:
|
|
555
|
+
"""Check TRAILING_STOP_MARKET order status.
|
|
556
|
+
|
|
557
|
+
Delegates to Algo Order API (``_check_trail_order_status``).
|
|
558
|
+
Algo order status lifecycle: NEW → WORKING → TRIGGERED → FINISHED.
|
|
559
|
+
Both TRIGGERED and FINISHED are treated as FILLED.
|
|
560
|
+
"""
|
|
561
|
+
return self._check_trail_order_status(symbol, order_id)
|
|
562
|
+
|
|
563
|
+
def _lookup_actual_fill(
|
|
564
|
+
self: "ScalperProtocol",
|
|
565
|
+
symbol: str,
|
|
566
|
+
expected_qty: str,
|
|
567
|
+
order_type: str,
|
|
568
|
+
) -> dict | None:
|
|
569
|
+
"""Look up actual fill price/qty from recent account trades.
|
|
570
|
+
|
|
571
|
+
v12.5.1: Called after algo order TRIGGERED to get real execution
|
|
572
|
+
data instead of relying on triggerPrice as proxy.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
symbol: Trading pair.
|
|
576
|
+
expected_qty: Expected fill quantity (from algo order).
|
|
577
|
+
order_type: 'STOP' or 'TAKE_PROFIT' to determine close side.
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
dict with 'price' and 'qty' if found, None otherwise.
|
|
581
|
+
"""
|
|
582
|
+
if self.paper_mode:
|
|
583
|
+
return None
|
|
584
|
+
try:
|
|
585
|
+
recent_trades = self.client.futures_account_trades(
|
|
586
|
+
symbol=symbol,
|
|
587
|
+
limit=20,
|
|
588
|
+
)
|
|
589
|
+
if not recent_trades:
|
|
590
|
+
return None
|
|
591
|
+
|
|
592
|
+
# Find the most recent trade with realized PnL (reduce-only fill)
|
|
593
|
+
expected_q = float(expected_qty) if expected_qty else 0
|
|
594
|
+
for t in reversed(recent_trades):
|
|
595
|
+
rpnl = float(t.get("realizedPnl", "0"))
|
|
596
|
+
tqty = float(t.get("qty", "0"))
|
|
597
|
+
if rpnl != 0 and abs(tqty - expected_q) / max(expected_q, 1e-8) < 0.05:
|
|
598
|
+
return {
|
|
599
|
+
"price": float(t["price"]),
|
|
600
|
+
"qty": tqty,
|
|
601
|
+
}
|
|
602
|
+
# Broader fallback: any recent trade with realized PnL
|
|
603
|
+
for t in reversed(recent_trades):
|
|
604
|
+
rpnl = float(t.get("realizedPnl", "0"))
|
|
605
|
+
if rpnl != 0:
|
|
606
|
+
return {
|
|
607
|
+
"price": float(t["price"]),
|
|
608
|
+
"qty": float(t.get("qty", "0")),
|
|
609
|
+
}
|
|
610
|
+
except Exception as e:
|
|
611
|
+
logger.warning(f"[{symbol}] Failed to lookup actual fill: {e}")
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
def _check_order_filled(self: "ScalperProtocol", symbol: str, order_id: str) -> dict | None:
|
|
615
|
+
"""Check if a server-side algo order has been filled.
|
|
616
|
+
|
|
617
|
+
Uses Binance Algo Order API (GET /fapi/v1/algoOrder).
|
|
618
|
+
Conditional algo orders transition through these states:
|
|
619
|
+
WORKING → TRIGGERED (brief) → FINISHED (terminal, filled)
|
|
620
|
+
We check for both TRIGGERED and FINISHED status.
|
|
621
|
+
|
|
622
|
+
v14.0.1: Fixed — Binance returns algoStatus='FINISHED' (not
|
|
623
|
+
'TRIGGERED') for conditional orders that have triggered AND
|
|
624
|
+
the resulting sub-order has filled. The old code only checked
|
|
625
|
+
for 'TRIGGERED', causing ALL server-side fills to be missed
|
|
626
|
+
and detected only by the fallback _reconcile_positions().
|
|
627
|
+
|
|
628
|
+
Returns a dict with 'avgPrice' and 'executedQty' if filled,
|
|
629
|
+
None otherwise.
|
|
630
|
+
"""
|
|
631
|
+
if self.paper_mode or not order_id or order_id.startswith("paper_"):
|
|
632
|
+
return None
|
|
633
|
+
|
|
634
|
+
try:
|
|
635
|
+
resp = self._algo_api.get_order(int(order_id))
|
|
636
|
+
algo_status = resp.get("algoStatus", "")
|
|
637
|
+
logger.info(
|
|
638
|
+
f"[{symbol}] Algo order check: algoId={order_id} status={algo_status} type={resp.get('orderType', '?')}"
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
# v14.0.1: FINISHED is the terminal state for a conditional
|
|
642
|
+
# order that has triggered and whose sub-order has filled.
|
|
643
|
+
# TRIGGERED is a brief intermediate state (trigger fired,
|
|
644
|
+
# sub-order placed but not yet filled). Both mean "filled".
|
|
645
|
+
if algo_status in ("TRIGGERED", "FINISHED"):
|
|
646
|
+
# Extract fill info from the algo order response.
|
|
647
|
+
# FINISHED provides actualPrice/actualQty from the
|
|
648
|
+
# actual sub-order fill; TRIGGERED may only have
|
|
649
|
+
# triggerPrice as a proxy.
|
|
650
|
+
actual_price = resp.get("actualPrice", "0")
|
|
651
|
+
actual_qty = resp.get("actualQty") or resp.get("quantity", "0")
|
|
652
|
+
trigger_price = resp.get("triggerPrice", "0")
|
|
653
|
+
|
|
654
|
+
logger.info(
|
|
655
|
+
f"[{symbol}] Algo order {algo_status}: algoId={order_id} "
|
|
656
|
+
f"type={resp.get('orderType')} "
|
|
657
|
+
f"triggerPrice={trigger_price} "
|
|
658
|
+
f"actualPrice={actual_price} qty={actual_qty}"
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
# v12.5.1: Get ACTUAL fill price/qty from account trades
|
|
662
|
+
# instead of using triggerPrice as proxy (which can differ
|
|
663
|
+
# by 0.1-0.5% from real execution during fast moves).
|
|
664
|
+
real_fill = self._lookup_actual_fill(
|
|
665
|
+
symbol,
|
|
666
|
+
actual_qty,
|
|
667
|
+
resp.get("orderType", ""),
|
|
668
|
+
)
|
|
669
|
+
if real_fill:
|
|
670
|
+
fill_price = real_fill["price"]
|
|
671
|
+
fill_qty = real_fill["qty"]
|
|
672
|
+
logger.info(f"[{symbol}] Actual fill from trades: price=${fill_price:.4f} qty={fill_qty}")
|
|
673
|
+
else:
|
|
674
|
+
# Fallback: use actualPrice or triggerPrice
|
|
675
|
+
fill_price = float(actual_price)
|
|
676
|
+
if fill_price == 0:
|
|
677
|
+
fill_price = float(trigger_price)
|
|
678
|
+
fill_qty = actual_qty
|
|
679
|
+
logger.warning(f"[{symbol}] Using algo trigger/actual price as fill proxy: ${fill_price:.4f}")
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
"avgPrice": str(fill_price),
|
|
683
|
+
"executedQty": str(fill_qty),
|
|
684
|
+
"status": "FILLED",
|
|
685
|
+
"algoId": order_id,
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
# v12.3.1: Detect externally cancelled/expired orders.
|
|
689
|
+
# Without this, a cancelled order keeps its algoId in our state,
|
|
690
|
+
# _sync_server_orders skips bracket repair (oid is not None),
|
|
691
|
+
# and the position silently loses server-side protection.
|
|
692
|
+
if algo_status in ALGO_ORDER_DEAD_STATUSES:
|
|
693
|
+
logger.warning(f"[{symbol}] Algo order {algo_status}: algoId={order_id} — will trigger bracket repair")
|
|
694
|
+
return {"status": "CANCELLED", "algoId": order_id}
|
|
695
|
+
except Exception as e:
|
|
696
|
+
logger.warning(f"[{symbol}] Algo order status check failed: algoId={order_id} error={e}")
|
|
697
|
+
return None
|
|
698
|
+
|
|
699
|
+
def _ratchet_runner_sl(self: "ScalperProtocol", symbol: str, pos: dict) -> None:
|
|
700
|
+
"""Ratchet server-side SL for a Phase 2 runner to protect profits.
|
|
701
|
+
|
|
702
|
+
v12.3.3: The runner's server SL starts at breakeven (stop_mult=0.0001).
|
|
703
|
+
As the runner's trailing stop floor climbs above breakeven, the server
|
|
704
|
+
SL should follow — otherwise a bot crash would lose all runner profits
|
|
705
|
+
above breakeven.
|
|
706
|
+
|
|
707
|
+
Only ratchets UPWARD (never moves SL against the position direction).
|
|
708
|
+
Checks every cycle but only places a new order when the computed
|
|
709
|
+
trail_stop has improved meaningfully (≥0.5% above last server SL).
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
symbol: Trading symbol.
|
|
713
|
+
pos: Position dict (mutated in place to update server_sl_order_id).
|
|
714
|
+
"""
|
|
715
|
+
trail_stop_pct = pos.get("_computed_trail_stop")
|
|
716
|
+
if trail_stop_pct is None or trail_stop_pct <= 0:
|
|
717
|
+
return # No improvement over breakeven yet
|
|
718
|
+
|
|
719
|
+
# Only ratchet if meaningful improvement (≥0.5% absolute gain above last)
|
|
720
|
+
last_server_floor = pos.get("_server_sl_pct", 0.0)
|
|
721
|
+
improvement = trail_stop_pct - last_server_floor
|
|
722
|
+
if improvement < 0.005: # 0.5% minimum step to avoid API spam
|
|
723
|
+
return
|
|
724
|
+
|
|
725
|
+
# Extract Binance symbol from compound key (e.g. BTCUSDT:LONG → BTCUSDT).
|
|
726
|
+
# Strip defensively in case pos['symbol'] was corrupted by old _load_state.
|
|
727
|
+
api_sym = to_api_symbol(symbol, pos)
|
|
728
|
+
|
|
729
|
+
entry_price = pos["entry_price"]
|
|
730
|
+
signal = pos["signal"]
|
|
731
|
+
|
|
732
|
+
# Convert trail_stop (return pct) to trigger price
|
|
733
|
+
if signal == 1: # Long: SL below
|
|
734
|
+
trigger_price = entry_price * (1.0 + trail_stop_pct)
|
|
735
|
+
close_side = "SELL"
|
|
736
|
+
buffer = -0.005 # 0.5% below trigger for limit
|
|
737
|
+
else: # Short: SL above
|
|
738
|
+
trigger_price = entry_price * (1.0 - trail_stop_pct)
|
|
739
|
+
close_side = "BUY"
|
|
740
|
+
buffer = 0.005 # 0.5% above trigger for limit
|
|
741
|
+
|
|
742
|
+
limit_price = trigger_price * (1.0 + buffer)
|
|
743
|
+
|
|
744
|
+
info = self.get_symbol_info(api_sym)
|
|
745
|
+
tick_size = info.get("tick_size", 0.01)
|
|
746
|
+
price_precision = info.get("price_precision", 2)
|
|
747
|
+
|
|
748
|
+
trigger_price = round_to_tick(trigger_price, tick_size, price_precision)
|
|
749
|
+
limit_price = round_to_tick(limit_price, tick_size, price_precision)
|
|
750
|
+
|
|
751
|
+
# v12.3.3 SAFETY: Place new SL FIRST, then cancel old.
|
|
752
|
+
# Reversing this order (cancel-then-place) leaves the runner
|
|
753
|
+
# unprotected if the bot crashes between the two API calls.
|
|
754
|
+
# Binance allows multiple algo stop orders to coexist briefly.
|
|
755
|
+
quantity = pos["notional"] / pos["entry_price"]
|
|
756
|
+
quantity = self._round_qty(quantity, api_sym)
|
|
757
|
+
if quantity <= 0:
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
new_sl = self._place_algo_order(
|
|
761
|
+
symbol=api_sym,
|
|
762
|
+
side=close_side,
|
|
763
|
+
order_type="STOP",
|
|
764
|
+
quantity=quantity,
|
|
765
|
+
trigger_price=trigger_price,
|
|
766
|
+
limit_price=limit_price,
|
|
767
|
+
label="runner-trail-sl",
|
|
768
|
+
position_side=pos.get("position_side"),
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
if new_sl and new_sl != "WOULD_TRIGGER":
|
|
772
|
+
# Success — now cancel old SL (runner always protected)
|
|
773
|
+
old_sl = pos.get("server_sl_order_id")
|
|
774
|
+
if old_sl:
|
|
775
|
+
self._cancel_server_stop_loss(api_sym, old_sl)
|
|
776
|
+
pos["server_sl_order_id"] = new_sl
|
|
777
|
+
pos["_server_sl_pct"] = trail_stop_pct
|
|
778
|
+
logger.info(
|
|
779
|
+
f"[{symbol}] Runner SL RATCHETED: "
|
|
780
|
+
f"{last_server_floor * 100:.2f}% → {trail_stop_pct * 100:.2f}% "
|
|
781
|
+
f"(trigger=${trigger_price:.4f})"
|
|
782
|
+
)
|
|
783
|
+
self._save_state()
|
|
784
|
+
elif new_sl == "WOULD_TRIGGER":
|
|
785
|
+
# Price already past trail stop — keep old SL, runner exits next cycle
|
|
786
|
+
logger.warning(
|
|
787
|
+
f"[{symbol}] Runner SL ratchet WOULD_TRIGGER — "
|
|
788
|
+
f"keeping old SL, update_position should catch this next bar"
|
|
789
|
+
)
|
|
790
|
+
else:
|
|
791
|
+
# Placement failed — keep old SL active (don't cancel!)
|
|
792
|
+
logger.warning(f"[{symbol}] Runner SL ratchet FAILED — keeping old SL at {last_server_floor * 100:.2f}%")
|
|
793
|
+
|
|
794
|
+
def _ratchet_trail_sl(self: "ScalperProtocol", symbol: str, pos: dict) -> None:
|
|
795
|
+
"""Ratchet server SL to trail_stop_gain price after trail activation.
|
|
796
|
+
|
|
797
|
+
r65: bar-close execution gap fix. Backtest assumes intrabar trail stop at
|
|
798
|
+
exact trail_stop_gain price; live code exits at bar-close (MARKET order).
|
|
799
|
+
This places/updates a Binance STOP order at the exact trail_stop_gain price
|
|
800
|
+
so Binance executes intrabar — matching backtest behavior.
|
|
801
|
+
|
|
802
|
+
Only ratchets UPWARD (never moves SL against position direction).
|
|
803
|
+
Guards against API spam with a minimum 0.1% improvement threshold.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
symbol: Compound position key (e.g. "BTCUSDT:LONG").
|
|
807
|
+
pos: Position dict (mutated to update server_sl_order_id).
|
|
808
|
+
"""
|
|
809
|
+
peak_gain = pos.get("peak_gain", 0.0)
|
|
810
|
+
if not pos.get("trail_active") or self.paper_mode:
|
|
811
|
+
return
|
|
812
|
+
|
|
813
|
+
trail_distance = self.trail_config.trail_distance
|
|
814
|
+
trail_stop_gain = max(peak_gain - trail_distance, 0.0)
|
|
815
|
+
|
|
816
|
+
# Only ratchet when meaningfully better than last ratcheted level
|
|
817
|
+
last_ratcheted = pos.get("_trail_sl_ratcheted", -999.0)
|
|
818
|
+
if trail_stop_gain - last_ratcheted < 0.001: # 0.1% minimum step
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
api_sym = to_api_symbol(symbol, pos)
|
|
822
|
+
|
|
823
|
+
entry_price = pos["entry_price"]
|
|
824
|
+
signal = pos["signal"]
|
|
825
|
+
|
|
826
|
+
if signal == 1: # LONG: SL is below current price
|
|
827
|
+
trigger_price = entry_price * (1.0 + trail_stop_gain)
|
|
828
|
+
close_side = "SELL"
|
|
829
|
+
buffer = -0.003 # 0.3% below trigger for limit
|
|
830
|
+
else: # SHORT: SL is above current price
|
|
831
|
+
trigger_price = entry_price * (1.0 - trail_stop_gain)
|
|
832
|
+
close_side = "BUY"
|
|
833
|
+
buffer = 0.003 # 0.3% above trigger for limit
|
|
834
|
+
|
|
835
|
+
limit_price = trigger_price * (1.0 + buffer)
|
|
836
|
+
|
|
837
|
+
info = self.get_symbol_info(api_sym)
|
|
838
|
+
tick_size = info.get("tick_size", 0.01)
|
|
839
|
+
price_precision = info.get("price_precision", 2)
|
|
840
|
+
|
|
841
|
+
trigger_price = round_to_tick(trigger_price, tick_size, price_precision)
|
|
842
|
+
limit_price = round_to_tick(limit_price, tick_size, price_precision)
|
|
843
|
+
|
|
844
|
+
# Place-first-cancel-after: position never left unprotected
|
|
845
|
+
quantity = pos["notional"] / pos["entry_price"]
|
|
846
|
+
quantity = self._round_qty(quantity, api_sym)
|
|
847
|
+
if quantity <= 0:
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
new_sl = self._place_algo_order(
|
|
851
|
+
symbol=api_sym,
|
|
852
|
+
side=close_side,
|
|
853
|
+
order_type="STOP",
|
|
854
|
+
quantity=quantity,
|
|
855
|
+
trigger_price=trigger_price,
|
|
856
|
+
limit_price=limit_price,
|
|
857
|
+
label="trail-ratchet-sl",
|
|
858
|
+
position_side=pos.get("position_side"),
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
if new_sl and new_sl != "WOULD_TRIGGER":
|
|
862
|
+
old_sl = pos.get("server_sl_order_id")
|
|
863
|
+
if old_sl:
|
|
864
|
+
self._cancel_server_stop_loss(api_sym, old_sl)
|
|
865
|
+
pos["server_sl_order_id"] = new_sl
|
|
866
|
+
pos["_trail_sl_ratcheted"] = trail_stop_gain
|
|
867
|
+
logger.info(
|
|
868
|
+
f"[{symbol}] Trail SL RATCHETED: "
|
|
869
|
+
f"trail_stop={trail_stop_gain * 100:.3f}% peak={peak_gain * 100:.3f}% "
|
|
870
|
+
f"trigger=${trigger_price:.4f}"
|
|
871
|
+
)
|
|
872
|
+
self._save_state()
|
|
873
|
+
elif new_sl == "WOULD_TRIGGER":
|
|
874
|
+
logger.warning(
|
|
875
|
+
f"[{symbol}] Trail SL ratchet WOULD_TRIGGER — "
|
|
876
|
+
f"bar-close exit will handle (trail_stop={trail_stop_gain * 100:.3f}%)"
|
|
877
|
+
)
|
|
878
|
+
else:
|
|
879
|
+
logger.warning(f"[{symbol}] Trail SL ratchet FAILED — old SL preserved")
|
|
880
|
+
|
|
881
|
+
def _verify_algo_order_active(self: "ScalperProtocol", algo_id: str, symbol: str) -> str | None:
|
|
882
|
+
"""Verify an algo order is still active on Binance.
|
|
883
|
+
|
|
884
|
+
Args:
|
|
885
|
+
algo_id: The algoId to query.
|
|
886
|
+
symbol: Trading symbol (for logging).
|
|
887
|
+
|
|
888
|
+
Returns:
|
|
889
|
+
"WORKING" if active, "EXPIRED"/"CANCELLED" if dead, None on query failure.
|
|
890
|
+
"""
|
|
891
|
+
if self.paper_mode:
|
|
892
|
+
return "WORKING"
|
|
893
|
+
try:
|
|
894
|
+
resp = self._algo_api.get_order(int(algo_id))
|
|
895
|
+
# Response may vary — handle both dict and list
|
|
896
|
+
if isinstance(resp, dict):
|
|
897
|
+
return resp.get("algoStatus") or resp.get("status")
|
|
898
|
+
return None
|
|
899
|
+
except Exception as e:
|
|
900
|
+
logger.warning(f"[{symbol}] Algo order status query failed for {algo_id}: {e}")
|
|
901
|
+
return None
|
|
902
|
+
|
|
903
|
+
def _check_bracket_staleness(self: "ScalperProtocol") -> None:
|
|
904
|
+
"""Check if any SL algo orders have expired or been cancelled.
|
|
905
|
+
|
|
906
|
+
Called each cycle from _sync_server_orders(). Detects externally cancelled
|
|
907
|
+
or expired SL orders so they can be re-placed by normal bracket repair logic.
|
|
908
|
+
"""
|
|
909
|
+
if self.paper_mode:
|
|
910
|
+
return
|
|
911
|
+
for sym in list(self.strategy.active_positions):
|
|
912
|
+
pos = self.strategy._positions.get(sym)
|
|
913
|
+
if not pos:
|
|
914
|
+
continue
|
|
915
|
+
sl_oid = pos.get("server_sl_order_id")
|
|
916
|
+
if not sl_oid or str(sl_oid).startswith("paper_"):
|
|
917
|
+
continue
|
|
918
|
+
api_sym = pos.get("symbol", sym.split(":")[0])
|
|
919
|
+
if ":" in api_sym:
|
|
920
|
+
api_sym = api_sym.split(":")[0]
|
|
921
|
+
status = self._verify_algo_order_active(str(sl_oid), api_sym)
|
|
922
|
+
if status in ALGO_ORDER_DEAD_STATUSES:
|
|
923
|
+
logger.warning(f"[{sym}] SL algo order {sl_oid} is {status} — re-placing bracket")
|
|
924
|
+
pos["server_sl_order_id"] = None
|
|
925
|
+
# Bracket will be re-placed by the normal bracket repair logic
|
|
926
|
+
self.discord.send(content=f"⚠️ **[{sym}] SL 브라켓 {status}** — 재배치 예정")
|
|
927
|
+
pos["bracket_sl_verified_at"] = datetime.now(timezone.utc).isoformat()
|
|
928
|
+
elif status == "WORKING":
|
|
929
|
+
pos["bracket_sl_verified_at"] = datetime.now(timezone.utc).isoformat()
|
|
930
|
+
|
|
931
|
+
def _verify_ratchet(self: "ScalperProtocol", sym: str, pos: dict, expected_price: float) -> bool:
|
|
932
|
+
"""Verify a ratchet SL update was accepted by Binance.
|
|
933
|
+
|
|
934
|
+
On failure: keeps old SL active + alerts. Does NOT force-close.
|
|
935
|
+
Force-close ONLY if no SL exists at all.
|
|
936
|
+
|
|
937
|
+
Args:
|
|
938
|
+
sym: Compound position key.
|
|
939
|
+
pos: Position dict.
|
|
940
|
+
expected_price: The trigger price of the newly placed ratchet SL.
|
|
941
|
+
|
|
942
|
+
Returns:
|
|
943
|
+
True if verified OK, False if verification failed or no SL at all.
|
|
944
|
+
"""
|
|
945
|
+
sl_oid = pos.get("server_sl_order_id")
|
|
946
|
+
if not sl_oid:
|
|
947
|
+
# No SL at all — this IS the unprotected case
|
|
948
|
+
logger.critical(f"[{sym}] NO SL EXISTS after ratchet — force-closing position")
|
|
949
|
+
self.discord.send(content=f"🚨 **[{sym}] SL 없음** — 강제 청산")
|
|
950
|
+
return False
|
|
951
|
+
|
|
952
|
+
api_sym = pos.get("symbol", sym.split(":")[0])
|
|
953
|
+
if ":" in api_sym:
|
|
954
|
+
api_sym = api_sym.split(":")[0]
|
|
955
|
+
status = self._verify_algo_order_active(str(sl_oid), api_sym)
|
|
956
|
+
if status != "WORKING":
|
|
957
|
+
# Ratchet failed — keep old SL, alert
|
|
958
|
+
logger.warning(f"[{sym}] Ratchet verification failed: SL {sl_oid} status={status}. Keeping old SL.")
|
|
959
|
+
self.discord.send(content=f"⚠️ **[{sym}] 래칫 검증 실패** — 기존 SL 유지, status={status}")
|
|
960
|
+
return False
|
|
961
|
+
|
|
962
|
+
# Log ratchet history
|
|
963
|
+
pos.setdefault("_ratchet_history", [])
|
|
964
|
+
pos["_ratchet_history"].append(
|
|
965
|
+
{
|
|
966
|
+
"price": expected_price,
|
|
967
|
+
"time": datetime.now(timezone.utc).isoformat(),
|
|
968
|
+
"verified": True,
|
|
969
|
+
}
|
|
970
|
+
)
|
|
971
|
+
pos["last_ratchet_price"] = expected_price
|
|
972
|
+
pos["last_ratchet_at"] = datetime.now(timezone.utc).isoformat()
|
|
973
|
+
return True
|