fix: make close_all robust for bypassed setups
This commit is contained in:
+114
-84
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user