fix: make close_all robust for bypassed setups

This commit is contained in:
pie
2026-05-07 12:00:57 +01:00
parent 1cfca22ddd
commit f2180891fc
+114 -84
View File
@@ -1,13 +1,16 @@
import time import time
import logging import logging
import os
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from src.api.client import Trading212Client from src.api.client import Trading212Client
from src.strategy.inverse_mapping import INVERSE_TICKER_MAP, LEVERAGE_MAP
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ExecutionManager: class ExecutionManager:
""" """
Manages the lifecycle of a trade: Entry, SL/TP placement, and Exit. 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): def __init__(self, client: Trading212Client):
self.client = client self.client = client
@@ -17,40 +20,61 @@ class ExecutionManager:
self.is_in_position = False self.is_in_position = False
def execute_trade(self, params: Dict[str, Any], target_risk_amount: float = 0.0): 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 self.params = params
ticker = params['ticker'] ticker = params['ticker']
base_ticker = ticker.split('_')[0]
direction = params['direction'] direction = params['direction']
entry_price = round(params['entry_price'], 2)
stop_loss = round(params['stop_loss'], 2)
# Calculate Risk per share self.is_etp = False
risk_per_share = abs(entry_price - stop_loss) self.leverage = 1.0
# Position Sizing # 1. ISA Mode Short Substitution
if target_risk_amount > 0 and risk_per_share > 0: if isa_mode and direction == "SELL":
quantity = round(target_risk_amount / risk_per_share, 4) # T212 allows fractional shares if base_ticker in INVERSE_TICKER_MAP:
# Enforce a minimum of 0.01 or whatever the broker allows, but we'll trust the math here inverse_ticker = INVERSE_TICKER_MAP[base_ticker]
if quantity < 0.01: self.leverage = LEVERAGE_MAP.get(inverse_ticker, 3.0)
quantity = 0.01 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: 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 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 immediate {direction} market order for {ticker} (Qty: {quantity})...")
logger.info(f"Placing entry {direction} limit order for {ticker} at {entry_price:.2f}")
try: 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') 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 return True
except Exception as e: 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 return False
def _is_ticker_in_portfolio(self, ticker: str) -> bool: def _is_ticker_in_portfolio(self, ticker: str) -> bool:
@@ -65,72 +89,85 @@ class ExecutionManager:
return False return False
def monitor_and_bracket(self, params: Dict[str, Any]): 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: if not self.current_order_id:
return False return False
ticker = params['ticker'] ticker = params.get('trading_ticker', params['ticker'])
tp_price = round(params['target_price'], 2)
sl_price = round(params['stop_loss'], 2)
quantity = getattr(self, 'current_quantity', 1.0) 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 import pytz
from datetime import datetime from datetime import datetime
tz = pytz.timezone('US/Eastern') tz = pytz.timezone('US/Eastern')
actual_entry_price = 0.0
while not self.is_in_position: while not self.is_in_position:
if datetime.now(tz).hour >= 11: if datetime.now(tz).hour >= 11:
logger.warning(f"11:00 EST reached without entry fill for {ticker}. Aborting.")
return False return False
try: try:
status_info = self.client.get_order_status(self.current_order_id) positions = self.client.get_all_open_positions()
status = status_info.get('status') for pos in positions:
logger.info(f"Entry order {self.current_order_id} status: {status}") if pos.get('ticker') == ticker:
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):
self.is_in_position = True 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 break
else: except Exception as e:
logger.warning(f"Order {self.current_order_id} disappeared and no position found. Assuming cancelled/rejected.") logger.debug(f"Waiting for market fill: {e}")
return False
logger.error(f"Error checking order status: {e}")
time.sleep(10) # Poll every 10 seconds time.sleep(2)
# Place SL and TP # Calculate brackets from ACTUAL fill price
# SL is a Stop order in the opposite direction target_pct = params.get('target_percent', 1.0) / 100.0 # as decimal
# TP is a Limit order in the opposite direction
sl_qty = -quantity if params['direction'] == "BUY" else quantity if is_etp:
tp_qty = -quantity if params['direction'] == "BUY" else quantity # 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: try:
# Place TP (Limit) logger.info(f"Placing protection for {ticker} (Fill: {actual_entry_price:.2f}): TP @ {tp_price}, SL @ {sl_price}")
tp_order = self.client.place_limit_order(ticker, tp_qty, tp_price, time_validity="GOOD_TILL_CANCEL") self.tp_order_id = self.client.place_limit_order(ticker, tp_qty, tp_price, time_validity="GOOD_TILL_CANCEL").get('id')
self.tp_order_id = tp_order.get('id') self.sl_order_id = self.client.place_stop_order(ticker, sl_qty, sl_price, time_validity="GOOD_TILL_CANCEL").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}")
return True return True
except Exception as e: 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 return False
def check_exit_status(self) -> tuple[bool, str, float]: def check_exit_status(self) -> tuple[bool, str, float]:
@@ -138,22 +175,21 @@ class ExecutionManager:
if not self.is_in_position: if not self.is_in_position:
return False, "", 0.0 return False, "", 0.0
ticker = self.params.get('ticker') ticker = self.params.get('trading_ticker', self.params.get('ticker'))
try: try:
if self.tp_order_id: if self.tp_order_id:
try: try:
tp_info = self.client.get_order_status(self.tp_order_id) tp_info = self.client.get_order_status(self.tp_order_id)
if tp_info.get('status') == "FILLED": 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 self.is_in_position = False
return True, "TP Hit", float(fill_price) return True, "TP Hit", float(fill_price)
except Exception as e: except Exception as e:
if "404" in str(e): if "404" in str(e):
if not self._is_ticker_in_portfolio(ticker): if not self._is_ticker_in_portfolio(ticker):
self.is_in_position = False 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", 0.0
return True, "TP Hit", float(self.params.get('target_price'))
else: else:
raise e raise e
@@ -161,15 +197,14 @@ class ExecutionManager:
try: try:
sl_info = self.client.get_order_status(self.sl_order_id) sl_info = self.client.get_order_status(self.sl_order_id)
if sl_info.get('status') == "FILLED": 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 self.is_in_position = False
return True, "SL Hit", float(fill_price) return True, "SL Hit", float(fill_price)
except Exception as e: except Exception as e:
if "404" in str(e): if "404" in str(e):
if not self._is_ticker_in_portfolio(ticker): if not self._is_ticker_in_portfolio(ticker):
self.is_in_position = False 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", 0.0
return True, "SL Hit", float(self.params.get('stop_loss'))
else: else:
raise e raise e
@@ -179,8 +214,11 @@ class ExecutionManager:
return False, "", 0.0 return False, "", 0.0
def close_all(self, ticker: str) -> float: def close_all(self, ticker: str) -> float:
"""Forces a close of all open orders and positions. Returns the exit price (or 0.0).""" """Forces a close of all open orders and positions. Returns the exit price."""
logger.info(f"Closing all orders and positions for {ticker}...") # 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: if self.current_order_id:
try: self.client.cancel_order(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) try: self.client.cancel_order(self.tp_order_id)
except: pass except: pass
logger.info("Emergency exit triggered. Cancelling pending orders...")
exit_price = 0.0 exit_price = 0.0
# Flatten any active position
if self.is_in_position: if self.is_in_position:
try: try:
positions = self.client.get_all_open_positions() positions = self.client.get_all_open_positions()
for pos in positions: for pos in positions:
if pos.get('ticker') == ticker: if pos.get('ticker') == trading_ticker:
qty = float(pos.get('quantity', 0)) 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: if qty != 0:
# To close, we sell if we are long (positive qty), buy if short (negative qty) self.client.place_market_order(trading_ticker, -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)
break break
except Exception as e: 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 self.is_in_position = False
logger.info("Cleanup complete.")
return exit_price return exit_price