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.
@@ -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