import time import logging import os import random from typing import Dict, Any, Optional from src.api.client import Trading212Client from src.strategy.inverse_mapping import INVERSE_TICKER_MAP, LEVERAGE_MAP logger = logging.getLogger(__name__) class ExecutionManager: """ Manages the lifecycle of a trade: Entry, SL/TP placement, and Exit. Supports Inverse ETPs for Shorting in ISA mode. """ def __init__(self, client: Trading212Client): self.client = client self.current_order_id = None self.sl_order_id = None self.tp_order_id = None self.is_in_position = False def _call_with_retry(self, func, *args, **kwargs): """Helper to call an API function with retries and jitter.""" import random max_attempts = 5 for attempt in range(max_attempts): try: return func(*args, **kwargs) except Exception as e: if '429' in str(e): wait = (2 ** attempt) + random.uniform(0.1, 1.0) logger.warning(f"Rate limited. Retrying in {wait:.1f}s...") time.sleep(wait) elif '400' in str(e) or '403' in str(e): # For 400/403, logging the body is crucial if hasattr(e, 'response') and e.response is not None: logger.error(f"API Error Body: {e.response.text}") raise e else: raise e raise Exception(f"Failed after {max_attempts} attempts") def execute_trade(self, params: Dict[str, Any], target_risk_amount: float = 0.0): """Starts the trade process by placing a MARKET entry order for immediate execution.""" isa_mode = os.getenv("ISA_MODE", "False").lower() == "true" self.params = params ticker = params['ticker'] base_ticker = ticker.split('_')[0] direction = params['direction'] self.is_etp = False self.leverage = 1.0 # 1. ISA Mode Short Substitution if isa_mode and direction == "SELL": if base_ticker in INVERSE_TICKER_MAP: inverse_ticker = INVERSE_TICKER_MAP[base_ticker] self.leverage = LEVERAGE_MAP.get(inverse_ticker, 3.0) logger.info(f"ISA Mode Active: Substituting SELL {ticker} with BUY {inverse_ticker} ({self.leverage}x Inverse ETP)") ticker = inverse_ticker direction = "BUY" self.is_etp = True self.params['trading_ticker'] = ticker else: logger.warning(f"ISA Mode Active: Cannot Short {ticker} and no inverse ETP found. Setup ignored.") return False else: self.params['trading_ticker'] = ticker # 2. Position Sizing approx_price = params.get('current_price', params['entry_price']) if self.is_etp: # Sizing for ETP: Default to 1.0 share if price unknown, otherwise could fetch. quantity = 1.0 logger.info(f"Sizing for ETP {ticker}: Defaulting to 1.0 share (Leverage: {self.leverage}x)") else: # We must use round() to avoid 400 Bad Request stop_loss = round(params['stop_loss'], 2) risk_per_share = abs(approx_price - stop_loss) if target_risk_amount > 0 and risk_per_share > 0: quantity = round(target_risk_amount / risk_per_share, 4) if quantity < 0.01: quantity = 0.01 else: quantity = 1.0 self.current_quantity = quantity trade_quantity = -quantity if direction == "SELL" else quantity logger.info(f"Placing immediate {direction} market order for {ticker} (Qty: {quantity})...") try: order = self._call_with_retry(self.client.place_market_order, ticker, trade_quantity) self.current_order_id = order.get('id') logger.info(f"Market order placed successfully. ID: {self.current_order_id}") return True except Exception as e: logger.error(f"Failed to place entry market order: {e}") return False def monitor_and_bracket(self, params: Dict[str, Any]): """Polls the entry and places SL/TP based on ACTUAL fill price.""" if not self.current_order_id: return False ticker = params.get('trading_ticker', params['ticker']) quantity = getattr(self, 'current_quantity', 1.0) direction = params['direction'] is_etp = getattr(self, 'is_etp', False) leverage = getattr(self, 'leverage', 1.0) # Wait for immediate fill confirmation import pytz from datetime import datetime tz = pytz.timezone('US/Eastern') actual_entry_price = 0.0 while not self.is_in_position: if datetime.now(tz).hour >= 11: return False try: positions = self._call_with_retry(self.client.get_all_open_positions) for pos in positions: if pos.get('ticker') == ticker: self.is_in_position = True actual_entry_price = float(pos.get('averagePrice', 0.0)) logger.info(f"Market filled! Actual Entry: {actual_entry_price:.2f}") break except Exception as e: logger.debug(f"Waiting for market fill: {e}") time.sleep(2) # Calculate brackets from ACTUAL fill price target_pct = params.get('target_percent', 1.0) / 100.0 # as decimal if is_etp: tp_move_pct = target_pct * leverage sl_move_pct = tp_move_pct / 2.0 # 1:2 RR tp_price = actual_entry_price * (1 + tp_move_pct) sl_price = actual_entry_price * (1 - sl_move_pct) tp_qty = -quantity sl_qty = -quantity else: range_size = params.get('range_size', 0) if direction == "BUY": # LONG tp_price = actual_entry_price + (range_size * 0.382) risk_distance = (tp_price - actual_entry_price) / 2.0 sl_price = actual_entry_price - risk_distance sl_qty = -quantity tp_qty = -quantity else: # SHORT (Normal stock) tp_price = actual_entry_price - (range_size * 0.382) risk_distance = (actual_entry_price - tp_price) / 2.0 sl_price = actual_entry_price + risk_distance sl_qty = quantity tp_qty = quantity tp_price = round(tp_price, 2) sl_price = round(sl_price, 2) # Save final calculated prices for PnL recording self.params['final_sl'] = sl_price self.params['final_tp'] = tp_price self.params['final_entry'] = actual_entry_price # Jitter before placing brackets to avoid 429 time.sleep(random.uniform(0.5, 2.0)) try: logger.info(f"Placing protection for {ticker} (Fill: {actual_entry_price:.2f}): TP @ {tp_price}, SL @ {sl_price}") self.tp_order_id = self._call_with_retry(self.client.place_limit_order, ticker, tp_qty, tp_price, time_validity="GOOD_TILL_CANCEL").get('id') self.sl_order_id = self._call_with_retry(self.client.place_stop_order, ticker, sl_qty, sl_price, time_validity="GOOD_TILL_CANCEL").get('id') return True except Exception as e: logger.error(f"Failed to place SL/TP brackets: {e}") return False def check_exit_status(self) -> tuple[bool, str, float]: """Checks if the SL or TP orders have been filled by the broker.""" if not self.is_in_position: return False, "", 0.0 ticker = self.params.get('trading_ticker', self.params.get('ticker')) try: if self.tp_order_id: try: tp_info = self._call_with_retry(self.client.get_order_status, self.tp_order_id) if tp_info.get('status') == "FILLED": fill_price = tp_info.get('filledPrice', tp_info.get('limitPrice', 0)) self.is_in_position = False return True, "TP Hit", float(fill_price) except Exception as e: if "404" in str(e): if not self._is_ticker_in_portfolio(ticker): self.is_in_position = False return True, "TP Hit", 0.0 else: raise e if self.sl_order_id: try: sl_info = self._call_with_retry(self.client.get_order_status, self.sl_order_id) if sl_info.get('status') == "FILLED": fill_price = sl_info.get('filledPrice', sl_info.get('stopPrice', 0)) self.is_in_position = False return True, "SL Hit", float(fill_price) except Exception as e: if "404" in str(e): if not self._is_ticker_in_portfolio(ticker): self.is_in_position = False return True, "SL Hit", 0.0 else: raise e except Exception as e: logger.error(f"Error checking exit status: {e}") return False, "", 0.0 def close_all(self, ticker: str) -> float: """Forces a close of all open orders and positions. Returns the exit price.""" # Robust lookup in case execute_trade bypassed early params = getattr(self, 'params', {}) trading_ticker = params.get('trading_ticker', ticker) logger.info(f"Closing all orders and positions for {trading_ticker}...") if self.current_order_id: try: self._call_with_retry(self.client.cancel_order, self.current_order_id) except: pass if self.sl_order_id: try: self._call_with_retry(self.client.cancel_order, self.sl_order_id) except: pass if self.tp_order_id: try: self._call_with_retry(self.client.cancel_order, self.tp_order_id) except: pass exit_price = 0.0 if self.is_in_position: try: # Portfolio check with retry and jitter positions = self._call_with_retry(self.client.get_all_open_positions) for pos in positions: if pos.get('ticker') == trading_ticker: qty = float(pos.get('quantity', 0)) exit_price = float(pos.get('currentPrice', 0.0)) if qty != 0: self._call_with_retry(self.client.place_market_order, trading_ticker, -qty) break except Exception as e: logger.error(f"Failed to flatten position: {e}") self.is_in_position = False return exit_price