-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexecution_engine.py
More file actions
689 lines (613 loc) · 34.7 KB
/
Copy pathexecution_engine.py
File metadata and controls
689 lines (613 loc) · 34.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
"""
SQUEEZE OS v5.0 — Tradier-First Execution Engine
═════════════════════════════════════════════════
Live execution via Tradier (primary) → Alpaca (fallback).
Auto-pilot attributes fully wired for server_v5.py workers.
PDT Shield, Shadow mode, GEX cache, and trade history included.
"""
import os
import json
import time
import logging
import pandas as pd
import numpy as np
from datetime import datetime
from typing import Dict, List, Optional, Any
from threading import Lock
try:
from delta_neutrality import DeltaNeutralityEngine
except ImportError:
DeltaNeutralityEngine = None
from core.execution_lock import claim_entry
try:
from BEAST.hedger.autonomous_hedger import AutonomousHedger, HedgerConfig
except ImportError:
AutonomousHedger = None
class HedgerConfig:
def __init__(self, **kw): pass
logger = logging.getLogger(__name__)
class ExecutionEngine:
def __init__(self, schwab_api, rmre_bridge, performance_tracker=None, discord_alerts=None):
# `schwab_api` kept in the signature for back-compat with callers that pass it positionally.
# Tradier-first stack passes None; actual broker is injected later via set_broker().
self.rmre = rmre_bridge
self.tracker = performance_tracker
self.discord = discord_alerts
self.lock = Lock()
# ── LIVE MODE ──
self.live_mode = os.environ.get('TRADIER_LIVE', 'false').lower() == 'true'
# Per-order dollar cap for THIS engine. Previously read BEAST_MAX_PRICE
# directly -- which core/api/convergence_bp.py also reads, but as a
# NOTIONAL BUDGET (`quantity = BEAST_MAX_PRICE // price`) with a 500.0
# default against this file's 25.0. One env var, two meanings, defaults
# 20x apart, so whatever is set on Render silently governs both at once.
# Same collision class as the MACRO_STACK_WARMUP bug (see CLAUDE.md).
#
# Falls back to BEAST_MAX_PRICE so an existing deployment's effective
# cap is completely unchanged until EXECUTION_MAX_ORDER_VALUE is set --
# this disambiguates going forward without silently retightening or
# loosening a live risk limit as a side effect of a rename.
self.max_order_value = float(
os.environ.get('EXECUTION_MAX_ORDER_VALUE',
os.environ.get('BEAST_MAX_PRICE', '25.0'))
)
# ── BROKER REFERENCE (Tradier preferred) ──
# Set after DataManager is available via set_broker()
self.broker = None
# ── PDT SHIELD ──
self.pdt_limit = 3
self.pdt_window_days = 5
self.day_trades: List[float] = []
# ── TRADE LOG ──
self.trade_log_path = 'trade_log.json'
self.active_trades: Dict[str, Dict] = {}
self._trade_history: List[Dict] = []
self.load_trades()
# ── AUTO-PILOT STATE (required by server_v5.py worker_autopilot) ──
self.autopilot_cooldown = 300 # 5-min cooldown between auto entries
self.last_autopilot_entry = 0.0
self.max_autopilot_trades = 2 # Max concurrent autopilot positions
# ── RISK MANAGEMENT ──
self.atr_multiplier = 1.5
self.meme_atr_multiplier = 2.5
# Delta engine — lazy init after broker is set
self.delta_engine = None
# ── GEX CACHE ──
self.gex_cache: Dict[str, Dict] = {}
self.last_gex_update = 0
self.beast_hedger = None
logger.info(f"[EXECUTION] Engine Ready | Live: {self.live_mode} | PDT: {len(self.day_trades)}/3")
# ─────────────────────────────────────────────────────────────
# BROKER WIRING
# ─────────────────────────────────────────────────────────────
def set_broker(self, data_manager):
"""Wire the preferred broker from DataManager (Tradier > Alpaca)."""
if data_manager is None:
return
tradier = getattr(data_manager, 'tradier', None)
alpaca = getattr(data_manager, 'alpaca', None)
if tradier and getattr(tradier, 'available', False):
self.broker = tradier
logger.info("[EXECUTION] Broker → Tradier LIVE")
elif alpaca and getattr(alpaca, 'available', False):
self.broker = alpaca
logger.info("[EXECUTION] Broker → Alpaca (fallback)")
else:
logger.warning("[EXECUTION] No live broker available — shadow-only mode")
# Init delta engine now that we have a broker reference
if DeltaNeutralityEngine:
try:
self.delta_engine = DeltaNeutralityEngine(self, self.rmre)
except Exception:
pass
# ─────────────────────────────────────────────────────────────
# PERSISTENCE
# ─────────────────────────────────────────────────────────────
def load_trades(self):
if os.path.exists(self.trade_log_path):
try:
with open(self.trade_log_path, 'r') as f:
data = json.load(f)
self.active_trades = data.get('active', {})
self.day_trades = data.get('day_trades', [])
self._trade_history = data.get('history', [])
self._prune_pdt()
except Exception as e:
logger.error(f"[EXECUTION] Load error: {e}")
self.active_trades = {}
self.day_trades = []
self._trade_history = []
def save_trades(self):
with self.lock:
try:
data = {
'active': self.active_trades,
'history': self._trade_history, # 100% FETCH: Full session history preserved
'day_trades': self.day_trades,
'last_updated': time.time()
}
with open(self.trade_log_path, 'w') as f:
json.dump(data, f, indent=4)
except Exception as e:
logger.error(f"[EXECUTION] Save error: {e}")
# ─────────────────────────────────────────────────────────────
# PUBLIC ACCESSORS (required by server_v5.py)
# ─────────────────────────────────────────────────────────────
def get_active_trades(self) -> List[Dict]:
with self.lock:
return list(self.active_trades.values())
def get_trade_history(self) -> List[Dict]:
with self.lock:
return list(self._trade_history) # 100% FETCH: No arbitrary truncation
# ─────────────────────────────────────────────────────────────
# PDT SHIELD
# ─────────────────────────────────────────────────────────────
def _prune_pdt(self):
now = time.time()
five_days_ago = now - (self.pdt_window_days * 86400)
self.day_trades = [t for t in self.day_trades if t > five_days_ago]
def check_pdt_shield(self) -> bool:
self._prune_pdt()
if len(self.day_trades) >= self.pdt_limit:
logger.warning(f"🛑 PDT SHIELD ACTIVE: {len(self.day_trades)}/3 trades used.")
return False
return True
# ─────────────────────────────────────────────────────────────
# REGIME VALIDATION
# ─────────────────────────────────────────────────────────────
def should_execute(self, symbol: str, side: str, is_live: bool = False) -> Dict[str, Any]:
if not self.rmre:
return {"allow": True, "reason": "RMRE Offline"}
try:
regime = self.rmre.compute_regime(symbol)
hurst = regime.get('hurst_val', 0.5)
label = regime.get('regime_label', 'UNKNOWN')
threshold = 0.62 if is_live else 0.55
if hurst > threshold and label in ('EXECUTION', 'CONFLICT'):
return {"allow": True, "reason": f"VALIDATED: {label} | Hurst: {hurst:.2f}"}
return {"allow": False, "reason": f"REJECTED: Hurst {hurst:.2f} < {threshold}"}
except Exception as e:
return {"allow": False, "reason": str(e)}
# ─────────────────────────────────────────────────────────────
# ATR
# ─────────────────────────────────────────────────────────────
def calculate_atr(self, symbol: str, period: int = 14) -> float:
if not self.tracker or not self.tracker.data_manager:
return 0.0
dm = self.tracker.data_manager
if not dm.polygon or not dm.polygon.available:
return 0.0
try:
aggs = dm.polygon.get_aggregates(symbol, 1, 'minute', limit=period + 5)
if not aggs or len(aggs) < period:
return 0.0
df = pd.DataFrame(aggs).sort_values('timestamp')
df['prev_close'] = df['close'].shift(1)
df['tr'] = np.maximum(
df['high'] - df['low'],
np.maximum(abs(df['high'] - df['prev_close']),
abs(df['low'] - df['prev_close']))
)
return float(df['tr'].tail(period).mean())
except Exception:
return 0.0
# ─────────────────────────────────────────────────────────────
# TRADE EXECUTION
# ─────────────────────────────────────────────────────────────
def execute_trade(self, symbol: str, side: str, quantity: int, price: float, reason: str = "Signal"):
"""Master entry point — routes to live or shadow based on TRADIER_LIVE env."""
if self.live_mode:
return self.execute_live_trade(symbol, side, quantity, price, reason)
return self.execute_shadow_trade(symbol, side, quantity, price, reason)
def _atr_stop_targets(self, symbol: str, side: str, price: float,
fallback_sl_pct: float = 0.04, fallback_tp_pct: float = 0.12) -> Dict[str, float]:
"""
ATR-based stop-loss/take-profit anchored to `price`, using the
already-wired atr_multiplier (meme_atr_multiplier for MANDATORY_TICKERS
— GME/AMC/IWM) via calculate_atr(). These fields existed but were never
used; SL/TP was hardcoded at fixed percentages regardless of a symbol's
actual volatility. Falls back to the caller's fixed-percentage bands
when ATR is unavailable (e.g. no Polygon key configured — calculate_atr
returns 0.0), so behavior degrades gracefully instead of landing at a
nonsensical distance.
"""
try:
from core.api.market_scanner import MANDATORY_TICKERS # lazy: avoids a
# module-load cycle (market_scanner -> core.legacy -> execution_engine)
is_meme = symbol.upper() in MANDATORY_TICKERS
except Exception:
is_meme = False
mult = self.meme_atr_multiplier if is_meme else self.atr_multiplier
atr = self.calculate_atr(symbol)
if atr and atr > 0:
if side == 'BUY':
return {"sl": round(price - atr * mult, 4), "tp": round(price + atr * mult * 2.5, 4)}
return {"sl": round(price + atr * mult, 4), "tp": round(price - atr * mult * 2.5, 4)}
if side == 'BUY':
return {"sl": round(price * (1 - fallback_sl_pct), 4), "tp": round(price * (1 + fallback_tp_pct), 4)}
return {"sl": round(price * (1 + fallback_sl_pct), 4), "tp": round(price * (1 - fallback_tp_pct), 4)}
def _register_for_exit_management(self, symbol: str, quantity: int,
fill_price: float, stop_price=None):
"""
Register a real LIVE fill with position_manager, which owns every
automated exit for it from that point on (hard stop, ATR trailing
stop, giveback lock, real broker sell with a live position check).
Best effort by design: a registration failure must never roll back an
order that already reached the broker. It is logged at ERROR because
an unregistered live position is exactly the unmanaged state this
exists to prevent.
"""
try:
import position_manager
atr = self.calculate_atr(symbol)
position_manager.register_equity(
symbol, quantity, fill_price, "CEO_TRADER",
atr_value=(atr if atr and atr > 0 else None),
stop_price=stop_price,
)
except Exception as e:
logger.error(
f"[EXEC] ⚠️ {symbol} filled but could NOT be registered with "
f"position_manager ({e}) — this live position has no active exit management"
)
def execute_shadow_trade(self, symbol: str, side: str, quantity: int, price: float = 0.0, reason: str = "Signal"):
validation = self.should_execute(symbol, side)
if not validation['allow']:
return {"status": "FILTERED", "reason": validation['reason']}
trade_id = f"SHADOW_{symbol}_{int(time.time())}"
levels = self._atr_stop_targets(symbol, side, price, fallback_sl_pct=0.05, fallback_tp_pct=0.15)
trade = {
'id': trade_id, 'symbol': symbol, 'side': side, 'qty': quantity,
'entry_price': price, 'current_price': price,
'sl': levels['sl'], 'tp': levels['tp'],
'status': 'OPEN', 'opened_at': time.time(), 'mode': 'SHADOW', 'reason': reason
}
with self.lock:
self.active_trades[trade_id] = trade
self.save_trades()
if self.discord:
try:
self.discord.fire_beast_trade_alert_full(trade, is_live=False)
except Exception:
pass
return trade
def execute_live_trade(self, symbol: str, side: str, quantity: int, price: float, reason: str = "Signal"):
# ── Safety checks ──
if quantity > 0 and price > 0 and (quantity * price) > self.max_order_value:
return {"status": "REJECTED", "reason": f"Value ${quantity*price:.2f} exceeds safety limit ${self.max_order_value}"}
if not self.check_pdt_shield():
if self.discord:
try:
self.discord.send_alert("⚠️ PDT BLOCK", "Trade rejected — 5-day window exhausted.")
except Exception:
pass
return {"status": "REJECTED", "reason": "PDT Shield Active"}
validation = self.should_execute(symbol, side, is_live=True)
if not validation['allow']:
return {"status": "FILTERED", "reason": validation['reason']}
if side == 'BUY':
# Spread guard — entries only, never exits. A market/marketable buy
# into a wide bid-ask spread on a thin name eats the whole spread as
# instant slippage; checked before claim_entry() below so a
# spread-rejected order doesn't waste the cross-engine claim on a
# trade we're not actually going to place. Fails open (no check) if
# a quote isn't available — this only ever blocks on data we
# actually have, never on missing data.
max_spread_pct = float(os.environ.get('TRADIER_MAX_SPREAD_PCT', '2.0'))
if max_spread_pct > 0:
try:
from tradier_api import get_spread_pct
spread_pct = get_spread_pct(symbol)
except Exception:
spread_pct = None
if spread_pct is not None and spread_pct > max_spread_pct:
logger.warning(f"[EXEC] {symbol} BUY skipped — spread {spread_pct:.2f}% > {max_spread_pct:.2f}% cap")
return {"status": "REJECTED", "reason": f"spread {spread_pct:.2f}% exceeds {max_spread_pct:.2f}% cap"}
# Cross-engine claim — this Tradier account is also traded by
# core/api/convergence_bp.py's GOD MODE execution and
# iam_executor.py's IAM execution, each with their own independent
# gate. A fresh buy has no natural cap, unlike a sell (checked
# against the real held quantity right below), so only the entry
# side needs coordination.
if not claim_entry(symbol, "LONG_ENTRY", "ceo_trader"):
logger.info(f"[EXEC] {symbol} LONG entry already claimed by another engine this window — skipping")
return {"status": "SKIPPED", "reason": "claimed by another engine"}
else:
# Position-aware sell — this engine previously placed a bare SELL
# for whatever quantity the caller computed, with no verification
# that the account actually held that many shares. Unlike a BUY,
# an unverified SELL isn't just "extra exposure" — it's a naked
# short with uncapped downside if nothing (or fewer shares) were
# actually held. Cap to the real position, same policy already
# applied to convergence_bp.py and iam_executor.py.
try:
from tradier_api import get_position
position = get_position(symbol)
except Exception as e:
logger.error(f"[EXEC] {symbol} position lookup failed: {e} — refusing to sell without verification")
return {"status": "REJECTED", "reason": f"position lookup failed: {e}"}
held = int(position["quantity"]) if position and position.get("quantity", 0) > 0 else 0
if held <= 0:
logger.info(f"[EXEC] {symbol} SELL signal — no existing long to close, skipping (no shorts)")
return {"status": "SKIPPED", "reason": "no position to close"}
if quantity > held:
logger.warning(f"[EXEC] {symbol} SELL requested {quantity}x but only {held}x held — capping to {held}")
quantity = held
logger.info(f"🚀 LIVE ORDER: {side} {quantity} {symbol} @ {price:.2f} | {reason}")
# ── Route to broker ──
res = {"status": "error", "message": "No broker configured"}
if self.broker and getattr(self.broker, 'available', False):
res = self.broker.place_order(symbol, quantity, side)
else:
# Try DataManager providers via tracker
dm = self.tracker.data_manager if self.tracker else None
if dm:
tradier = getattr(dm, 'tradier', None)
alpaca = getattr(dm, 'alpaca', None)
if tradier and getattr(tradier, 'available', False):
res = tradier.place_order(symbol, quantity, side)
elif alpaca and getattr(alpaca, 'available', False):
res = alpaca.place_order(symbol, quantity, side)
if res.get('status') == 'success':
oid = res.get('order_id', str(int(time.time())))
trade_id = f"LIVE_{symbol}_{oid}"
# ── Fill verification ── the broker only confirmed the order was
# ACCEPTED, not that it filled or at what price. Polling here closes
# that gap: entry_price (and therefore SL/TP) is anchored to the
# real average fill price when we can confirm one, instead of the
# pre-trade signal price — which can drift from reality on the thin
# $1-$50 names this system targets. fill_verified=False downstream
# means "treat entry_price as an estimate, not a confirmed fill."
fill_verified = False
fill_price = price
try:
from tradier_api import poll_order_fill
fill = poll_order_fill(oid)
if fill.get("filled"):
fill_verified = True
if fill.get("avg_fill_price"):
fill_price = fill["avg_fill_price"]
else:
logger.warning(
f"[EXEC] {symbol} order {oid} not confirmed filled after poll "
f"(status={fill.get('status')}) — entry_price is the pre-trade signal "
f"price, not a verified fill; check the account manually."
)
except Exception as e:
logger.warning(f"[EXEC] {symbol} fill poll failed: {e} — entry_price unverified")
if side == 'BUY':
levels = self._atr_stop_targets(symbol, side, fill_price, fallback_sl_pct=0.04, fallback_tp_pct=0.12)
trade = {
'id': trade_id, 'symbol': symbol, 'side': side, 'qty': quantity,
'entry_price': fill_price, 'current_price': fill_price,
'signal_price': price, 'fill_verified': fill_verified,
'sl': levels['sl'], 'tp': levels['tp'],
'status': 'OPEN', 'opened_at': time.time(), 'mode': 'LIVE',
'order_id': oid, 'reason': reason
}
with self.lock:
self.active_trades[trade_id] = trade
self.save_trades()
# Hand the real fill to the active exit manager. Before this,
# a CEOTrader/ExecutionEngine live position had NO working exit
# of any kind: the sl/tp above are computed and stored, but the
# only code that reads them (update_live_prices) is called by
# nothing in this repo, and _close_trade_unsafe -- the function
# it would call -- places no broker order at all, it just drops
# the bookkeeping row and posts a "TRADE CLOSED" Discord alert
# for a position that is in fact still open. Registering here
# gives these positions the same real hard stop / ATR trail /
# giveback lock every IAM position already gets.
self._register_for_exit_management(symbol, quantity, fill_price, levels.get('sl'))
if self.discord:
try:
self.discord.fire_beast_trade_alert_full(trade, is_live=True)
except Exception:
pass
logger.info(f"✅ LIVE TRADE RECORDED: {trade_id}")
return trade
# ── Closing SELL ──────────────────────────────────────────────
# This order reduced/closed an existing long — it never opened a
# new short (naked shorts are refused above). Close the matching
# tracked BUY entry/entries at the real fill price instead of
# recording this as a brand-new "OPEN" position with a synthetic
# SL/TP. The old behavior left a fictional short sitting in
# active_trades with stop/target levels for a position the
# account never actually held — it could later "close" on its
# own and feed made-up P&L into performance_tracker, while the
# real BUY entry's tracking was orphaned forever (both Engine 7's
# liquidation lookup and this file's own bookkeeping depend on
# active_trades reflecting real positions, not phantom ones).
# Treats the sell as a full close of every tracked open BUY entry
# for this symbol — consistent with this method's existing SELL
# semantics above (cap to `held`, never a partial scale-out).
closed_trades = []
with self.lock:
matching_ids = [
tid for tid, t in self.active_trades.items()
if t.get('symbol') == symbol and t.get('side') == 'BUY' and t.get('status') == 'OPEN'
]
for tid in matching_ids:
self.active_trades[tid]['current_price'] = fill_price
closed = self._close_trade_unsafe(tid)
if closed:
closed_trades.append(closed)
if not closed_trades:
# No tracked BUY entry to close (position was opened outside
# this engine's bookkeeping — e.g. manually, or by another
# engine). Still record that a real sell happened, as a
# standalone closed entry, so trade_log.json stays a true
# record of every live order this engine placed.
trade = {
'id': trade_id, 'symbol': symbol, 'side': side, 'qty': quantity,
'entry_price': fill_price, 'current_price': fill_price,
'signal_price': price, 'fill_verified': fill_verified,
'sl': None, 'tp': None, 'pnl': 0.0,
'status': 'CLOSED', 'opened_at': time.time(), 'closed_at': time.time(),
'mode': 'LIVE', 'order_id': oid, 'reason': reason,
}
with self.lock:
self._trade_history.insert(0, trade)
self.save_trades()
closed_trades = [trade]
logger.info(f"[EXEC] {symbol} SELL {quantity}x @ ${fill_price:.2f} closed a position not tracked in active_trades — logged standalone record")
else:
self.save_trades()
logger.info(f"✅ LIVE SELL RECORDED: {trade_id} — closed {len(closed_trades)} tracked position(s) @ ${fill_price:.2f}")
return closed_trades[-1]
logger.error(f"🛑 LIVE ORDER FAILED: {res}")
return res
# ─────────────────────────────────────────────────────────────
# PRICE MANAGEMENT & EXIT
# ─────────────────────────────────────────────────────────────
def update_live_prices(self, quotes: Dict[str, Dict]):
with self.lock:
to_close = []
for tid, trade in self.active_trades.items():
sym = trade['symbol']
if sym in quotes:
price = float(quotes[sym].get('price', trade['current_price']))
trade['current_price'] = price
if trade['side'] == 'BUY':
if price <= trade['sl'] or price >= trade['tp']:
to_close.append(tid)
else:
if price >= trade['sl'] or price <= trade['tp']:
to_close.append(tid)
for tid in to_close:
self._close_trade_unsafe(tid)
if to_close:
self.save_trades()
def close_trade(self, trade_id: str):
with self.lock:
result = self._close_trade_unsafe(trade_id)
self.save_trades()
return result
def _close_trade_unsafe(self, trade_id: str):
"""Must be called with self.lock held."""
if trade_id not in self.active_trades:
return None
trade = self.active_trades.pop(trade_id)
trade['status'] = 'CLOSED'
trade['closed_at'] = time.time()
# A LIVE trade closing here used to be pure fiction: this function
# never placed a broker order, so it dropped the tracking row, booked
# a P&L number off `current_price`, and fired a "TRADE CLOSED" Discord
# alert while the real Tradier position stayed open indefinitely. Route
# the real close through position_manager, which verifies the held
# quantity against the live account before selling and self-heals if
# something already closed it — so this stays correct whether or not
# the exit manager got there first, and can never double-sell.
if trade.get('mode') == 'LIVE':
try:
import position_manager
close_result = position_manager.close_position(
trade['symbol'], f"execution_engine close of {trade_id}"
)
trade['broker_close'] = close_result
except Exception as e:
logger.error(
f"[EXECUTION] ⚠️ {trade_id} bookkeeping closed but the real broker "
f"close FAILED ({e}) — the live position may still be open; "
f"the P&L below is modelled, not a confirmed fill"
)
trade['broker_close'] = {"status": "error", "message": str(e)}
# PDT tracking for live trades opened today
if trade.get('mode') == 'LIVE':
opened_day = datetime.fromtimestamp(trade['opened_at']).date()
if opened_day == datetime.now().date():
self.day_trades.append(time.time())
logger.info(f"📊 PDT RECORDED: {len(self.day_trades)}/3")
pnl = (trade['current_price'] - trade['entry_price']) * trade['qty']
if trade['side'] == 'SELL':
pnl *= -1
trade['pnl'] = pnl
# Feed CEOTrader's daily-loss circuit breaker (bolted onto this
# instance via hasattr in core/ceo_trader.py's __init__, not native
# to ExecutionEngine) — without this, daily_pnl never moves and
# _check_circuit_breaker() can never trip no matter how much real
# money is lost in a session.
if hasattr(self, 'daily_pnl'):
self.daily_pnl += pnl
self._trade_history.insert(0, trade)
# Institutional retention: cap at 10000 entries, no arbitrary truncation during session.
if len(self._trade_history) > 10000:
self._trade_history = self._trade_history[:10000]
if self.discord:
try:
color = 0x00FF88 if pnl > 0 else 0xFF4444
self.discord.send_alert(
f"💰 TRADE CLOSED: {trade['symbol']}",
f"PnL: **${pnl:+.2f}** | Exit: ${trade['current_price']:.2f}",
color=color
)
except Exception:
pass
if self.tracker:
self.tracker.add_trade_result(trade['pnl'], is_hedge=trade.get('is_hedge', False))
logger.info(f"[EXECUTION] Closed {trade_id} | PnL: ${trade['pnl']:.2f}")
if self.discord:
try:
self.discord.fire_beast_exit_alert(trade, is_live=self.live_mode)
except Exception as e:
logger.warning(f"[EXECUTION] Exit alert failed: {e}")
return trade
# ─────────────────────────────────────────────────────────────
# GEX / GAMMA WALLS (required by server_v5.py)
# ─────────────────────────────────────────────────────────────
def get_gamma_walls(self, symbol: str) -> Dict:
"""
Returns GEX metrics for a symbol.
BUG FIX (2026-07-30): this used to try to instantiate
`BEAST.gex.sml_gex_engine.GEXEngine`, a module that does not exist
anywhere in this codebase (confirmed by search) -- the import at the
top of this file was already wrapped in try/except ImportError and
silently set GEXEngine=None, so this method has always returned the
hardcoded all-zero dict below, for every symbol, unconditionally.
Fixed to call the real, already-live GEX engine
(gamma_flow_engine.calculate_gex_profile()) that already powers
Oracle/Gamma Pin/Squeeze Fuel today, fetching a real Tradier chain
via tradier_api.get_option_chain_schwab_format() -- the exact same
pattern gamma_pin_scanner.py/squeeze_fuel_scanner.py already use.
`inventory_z`/`hjb_hedge_rate` are NOT provided by GEXProfile -- those
are a separate Kalman/HJB computation embedded in
gamma_flow_engine.py's MM-Intel section, not part of this GEX
profile. Left at 0.0, disclosed here rather than silently guessed.
"""
now = time.time()
cached = self.gex_cache.get(symbol)
if cached and (now - cached.get('ts', 0)) < 300:
return cached
result = {
'symbol': symbol,
'regime': 'NEUTRAL',
'call_wall': 0.0,
'put_wall': 0.0,
'zero_gamma_line': 0.0,
'max_oi_strike': 0.0,
'total_gex': 0.0,
'inventory_z': 0.0, # not sourced from GEXProfile -- see docstring
'hjb_hedge_rate': 0.0, # not sourced from GEXProfile -- see docstring
'ts': now
}
try:
import tradier_api
from gamma_flow_engine import calculate_gex_profile
raw_chain = tradier_api.get_option_chain_schwab_format(symbol)
spot = float((raw_chain or {}).get('underlyingPrice', 0) or 0)
if raw_chain and spot > 0:
profile = calculate_gex_profile(raw_chain, spot, symbol)
if profile:
result.update({
'regime': profile.profile_shape.upper(),
'call_wall': profile.call_wall,
'put_wall': profile.put_wall,
'zero_gamma_line': profile.zero_gamma_line,
'max_oi_strike': profile.max_oi_strike,
'total_gex': profile.total_gex,
'ts': now,
})
except Exception as e:
logger.debug(f"[GEX] {symbol}: {e}")
self.gex_cache[symbol] = result
return result