From f2180891fc50d4aad804a2bbedd5fe9b1f6afc7e Mon Sep 17 00:00:00 2001 From: pie Date: Thu, 7 May 2026 12:00:57 +0100 Subject: [PATCH] fix: make close_all robust for bypassed setups --- src/execution/manager.py | 198 ++++++++++++++++++++++----------------- 1 file changed, 114 insertions(+), 84 deletions(-) diff --git a/src/execution/manager.py b/src/execution/manager.py index 23f13f3..065d565 100644 --- a/src/execution/manager.py +++ b/src/execution/manager.py @@ -1,13 +1,16 @@ import time import logging +import os 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 @@ -17,40 +20,61 @@ class ExecutionManager: self.is_in_position = False def execute_trade(self, params: Dict[str, Any], target_risk_amount: float = 0.0): - """Starts the trade process by placing a limit entry order.""" + """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'] - entry_price = round(params['entry_price'], 2) - stop_loss = round(params['stop_loss'], 2) - # Calculate Risk per share - risk_per_share = abs(entry_price - stop_loss) + self.is_etp = False + self.leverage = 1.0 - # Position Sizing - if target_risk_amount > 0 and risk_per_share > 0: - quantity = round(target_risk_amount / risk_per_share, 4) # T212 allows fractional shares - # Enforce a minimum of 0.01 or whatever the broker allows, but we'll trust the math here - if quantity < 0.01: - quantity = 0.01 + # 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: - quantity = 1.0 # Fallback + self.params['trading_ticker'] = ticker - self.current_quantity = quantity # Save it so monitor_and_bracket can use it + # 2. Position Sizing + approx_price = params.get('current_price', params['entry_price']) - # Quantity must be negative for Sell (Short) + if self.is_etp: + # For ETPs, we default to 1.0 share for now as we don't have a live ETP price feed. + quantity = 1.0 + logger.info(f"Sizing for ETP {ticker}: Defaulting to 1.0 share (Leverage: {self.leverage}x)") + else: + 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"Calculated Risk/Share: {risk_per_share:.2f}. Sizing position to {quantity} shares to risk ~{target_risk_amount:.2f}") - logger.info(f"Placing entry {direction} limit order for {ticker} at {entry_price:.2f}") + logger.info(f"Placing immediate {direction} market order for {ticker} (Qty: {quantity})...") try: - order = self.client.place_limit_order(ticker, trade_quantity, entry_price) + order = self.client.place_market_order(ticker, trade_quantity) self.current_order_id = order.get('id') - logger.info(f"Order placed successfully. ID: {self.current_order_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 order: {e}") + logger.error(f"Failed to place entry market order: {e}") return False def _is_ticker_in_portfolio(self, ticker: str) -> bool: @@ -65,72 +89,85 @@ class ExecutionManager: return False def monitor_and_bracket(self, params: Dict[str, Any]): - """Polls the entry order and places SL/TP once filled.""" + """Polls the entry and places SL/TP based on ACTUAL fill price.""" if not self.current_order_id: return False - ticker = params['ticker'] - tp_price = round(params['target_price'], 2) - sl_price = round(params['stop_loss'], 2) + 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 entry fill + # 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: - logger.warning(f"11:00 EST reached without entry fill for {ticker}. Aborting.") return False try: - status_info = self.client.get_order_status(self.current_order_id) - status = status_info.get('status') - logger.info(f"Entry order {self.current_order_id} status: {status}") - - if status == "FILLED": - self.is_in_position = True - logger.info(f"Entry order filled! Placing SL/TP.") - break - elif status in ["CANCELLED", "REJECTED"]: - logger.warning(f"Entry order was {status}. Aborting.") - return False - except Exception as e: - # Trading212 404s if an order is no longer active (filled or cancelled) - if "404" in str(e): - if self._is_ticker_in_portfolio(ticker): + positions = self.client.get_all_open_positions() + for pos in positions: + if pos.get('ticker') == ticker: self.is_in_position = True - logger.info(f"Order {self.current_order_id} disappeared but position detected. Assuming filled.") + actual_entry_price = float(pos.get('averagePrice', 0.0)) + logger.info(f"Market filled! Actual Entry: {actual_entry_price:.2f}") break - else: - logger.warning(f"Order {self.current_order_id} disappeared and no position found. Assuming cancelled/rejected.") - return False - logger.error(f"Error checking order status: {e}") + except Exception as e: + logger.debug(f"Waiting for market fill: {e}") - time.sleep(10) # Poll every 10 seconds + time.sleep(2) - # Place SL and TP - # SL is a Stop order in the opposite direction - # TP is a Limit order in the opposite direction - sl_qty = -quantity if params['direction'] == "BUY" else quantity - tp_qty = -quantity if params['direction'] == "BUY" else quantity + # Calculate brackets from ACTUAL fill price + target_pct = params.get('target_percent', 1.0) / 100.0 # as decimal + + if is_etp: + # ETP moves in 'opposite' direction to stock + # BUYing an Inverse ETP means we want it to go UP (Stock goes DOWN) + 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) + + # Since we bought the ETP, brackets are always SELL + 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 try: - # Place TP (Limit) - tp_order = self.client.place_limit_order(ticker, tp_qty, tp_price, time_validity="GOOD_TILL_CANCEL") - self.tp_order_id = tp_order.get('id') - logger.info(f"TP order placed: {self.tp_order_id}") - - # Place SL (Stop) - sl_order = self.client.place_stop_order(ticker, sl_qty, sl_price, time_validity="GOOD_TILL_CANCEL") - self.sl_order_id = sl_order.get('id') - logger.info(f"SL order placed: {self.sl_order_id}") - + logger.info(f"Placing protection for {ticker} (Fill: {actual_entry_price:.2f}): TP @ {tp_price}, SL @ {sl_price}") + self.tp_order_id = self.client.place_limit_order(ticker, tp_qty, tp_price, time_validity="GOOD_TILL_CANCEL").get('id') + self.sl_order_id = 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 orders: {e}") + logger.error(f"Failed to place SL/TP brackets: {e}") return False def check_exit_status(self) -> tuple[bool, str, float]: @@ -138,22 +175,21 @@ class ExecutionManager: if not self.is_in_position: return False, "", 0.0 - ticker = self.params.get('ticker') + ticker = self.params.get('trading_ticker', self.params.get('ticker')) try: if self.tp_order_id: try: tp_info = 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', self.params.get('target_price'))) + 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 - logger.info(f"TP order {self.tp_order_id} disappeared and position closed. Assuming TP hit.") - return True, "TP Hit", float(self.params.get('target_price')) + return True, "TP Hit", 0.0 else: raise e @@ -161,15 +197,14 @@ class ExecutionManager: try: sl_info = 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', self.params.get('stop_loss'))) + 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 - logger.info(f"SL order {self.sl_order_id} disappeared and position closed. Assuming SL hit.") - return True, "SL Hit", float(self.params.get('stop_loss')) + return True, "SL Hit", 0.0 else: raise e @@ -179,8 +214,11 @@ class ExecutionManager: return False, "", 0.0 def close_all(self, ticker: str) -> float: - """Forces a close of all open orders and positions. Returns the exit price (or 0.0).""" - logger.info(f"Closing all orders and positions for {ticker}...") + """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.client.cancel_order(self.current_order_id) @@ -192,27 +230,19 @@ class ExecutionManager: try: self.client.cancel_order(self.tp_order_id) except: pass - logger.info("Emergency exit triggered. Cancelling pending orders...") - exit_price = 0.0 - # Flatten any active position if self.is_in_position: try: positions = self.client.get_all_open_positions() for pos in positions: - if pos.get('ticker') == ticker: + if pos.get('ticker') == trading_ticker: qty = float(pos.get('quantity', 0)) - exit_price = float(pos.get('currentPrice', 0.0)) # Use current market price as approx fill + exit_price = float(pos.get('currentPrice', 0.0)) if qty != 0: - # To close, we sell if we are long (positive qty), buy if short (negative qty) - exit_qty = -qty - logger.info(f"Flattening position: Placing market order for {exit_qty} shares of {ticker} at approx {exit_price}") - self.client.place_market_order(ticker, exit_qty) + self.client.place_market_order(trading_ticker, -qty) break except Exception as e: - logger.error(f"Failed to flatten position during emergency exit: {e}") + logger.error(f"Failed to flatten position: {e}") self.is_in_position = False - logger.info("Cleanup complete.") return exit_price -