Files
trading-bot/src/execution/manager.py
T

306 lines
14 KiB
Python

import time
import logging
import os
import random
import json
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 placement, and Exit.
Uses a Hybrid Strategy: Broker-side SL and Bot-side TP monitoring.
"""
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
self.leverage = 1.0
self.is_etp = False
def _call_with_retry(self, func, *args, **kwargs):
"""Helper to call an API function with retries and jitter."""
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, max_capital: 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
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
approx_price = params.get('current_price', params['entry_price'])
# 2. Position Sizing
if self.is_etp:
quantity = 1.0
logger.info(f"Sizing for ETP {ticker}: Initial attempt with 1.0 share.")
else:
stop_loss = round(params['stop_loss'], 2)
risk_per_share = abs(approx_price - stop_loss)
q_risk = target_risk_amount / risk_per_share if risk_per_share > 0 else 0
q_capital = (max_capital * 0.95) / approx_price if approx_price > 0 else 0
if q_risk > 0 and q_capital > 0:
quantity = round(min(q_risk, q_capital), 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"Attempting {direction} market order for {ticker} (Qty: {quantity})...")
# 3. Execution with Smart Retry for Common Broker Errors
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:
if hasattr(e, 'response') and e.response is not None:
try:
err_data = e.response.json()
err_type = err_data.get('type', '')
err_detail = err_data.get('detail', '')
# Error A: Quantity Precision Mismatch
if "precision-mismatch" in err_type or "precision" in err_detail.lower():
logger.warning(f"Precision mismatch for {ticker}. Retrying with 2 decimal places...")
trade_quantity = round(trade_quantity, 2)
self.current_quantity = abs(trade_quantity)
order = self._call_with_retry(self.client.place_market_order, ticker, trade_quantity)
self.current_order_id = order.get('id')
return True
# Error B: Minimum Quantity Exceeded
if "min-quantity-exceeded" in err_type:
import re
match = re.search(r"at least ([\d.]+)", err_detail)
if match:
min_qty = float(match.group(1))
if (min_qty * approx_price) <= (max_capital * 1.05): # Small buffer
logger.warning(f"Quantity too low for {ticker}. Upping to minimum: {min_qty}")
trade_quantity = -min_qty if direction == "SELL" else min_qty
self.current_quantity = min_qty
order = self._call_with_retry(self.client.place_market_order, ticker, trade_quantity)
self.current_order_id = order.get('id')
return True
else:
logger.error(f"Required minimum {min_qty} exceeds available capital for {ticker}.")
except Exception as retry_e:
logger.error(f"Retry logic failed for {ticker}: {retry_e}")
logger.error(f"Failed to place entry market order for {ticker}: {e}")
return False
def _is_ticker_in_portfolio(self, ticker: str) -> bool:
"""Helper to check if a ticker currently has an open position."""
try:
positions = self._call_with_retry(self.client.get_all_open_positions)
for pos in positions:
if pos.get('ticker') == ticker:
return True
except Exception as e:
logger.error(f"Error checking portfolio: {e}")
return False
def monitor_and_bracket(self, params: Dict[str, Any]):
"""Polls the entry and places physical Stop Loss at the broker."""
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)
actual_entry_price = 0.0
while not self.is_in_position:
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)
target_pct = params.get('target_percent', 1.0) / 100.0
if is_etp:
tp_move_pct = target_pct * leverage
sl_move_pct = tp_move_pct / 2.0
tp_price = actual_entry_price * (1 + tp_move_pct)
sl_price = actual_entry_price * (1 - sl_move_pct)
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
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_price = round(tp_price, 2)
sl_price = round(sl_price, 2)
self.params['final_sl'] = sl_price
self.params['final_tp'] = tp_price
self.params['final_entry'] = actual_entry_price
try:
logger.info(f"Hybrid Mode: Placing Broker SL for {ticker} @ {sl_price}. Monitoring TP @ {tp_price} manually.")
# Use retry with possible precision fix for SL too
try:
sl_order = self._call_with_retry(self.client.place_stop_order, ticker, sl_qty, sl_price, time_validity="GOOD_TILL_CANCEL")
self.sl_order_id = sl_order.get('id')
except Exception as sl_e:
if "precision" in str(sl_e).lower():
logger.warning(f"Precision mismatch for {ticker} Stop. Retrying with 2 decimals...")
sl_qty = round(sl_qty, 2)
sl_order = self._call_with_retry(self.client.place_stop_order, ticker, sl_qty, sl_price, time_validity="GOOD_TILL_CANCEL")
self.sl_order_id = sl_order.get('id')
else:
raise sl_e
return True
except Exception as e:
logger.error(f"Failed to place SL bracket for {ticker}: {e}")
return False
def check_exit_status(self) -> tuple[bool, str, float]:
"""Checks if SL triggered at broker OR if TP target was hit in market."""
if not self.is_in_position:
return False, "", 0.0
ticker = self.params.get('trading_ticker', self.params.get('ticker'))
tp_target = self.params.get('final_tp', 0.0)
direction = self.params.get('direction', 'BUY')
is_etp = getattr(self, 'is_etp', False)
try:
positions = self._call_with_retry(self.client.get_all_open_positions)
current_price = 0.0
for pos in positions:
if pos.get('ticker') == ticker:
current_price = float(pos.get('currentPrice', 0.0))
break
if current_price > 0:
if (direction == "BUY" or is_etp) and current_price >= tp_target:
logger.info(f"Take Profit Target Reached! Price: {current_price} >= {tp_target}")
self.close_all(ticker)
return True, "TP Hit (Bot)", current_price
elif direction == "SELL" and not is_etp and current_price <= tp_target:
logger.info(f"Take Profit Target Reached! Price: {current_price} <= {tp_target}")
self.close_all(ticker)
return True, "TP Hit (Bot)", current_price
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 (Broker)", 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
fallback_price = float(self.params.get('final_sl', 0.0))
return True, "SL Hit (Broker)", fallback_price
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."""
params = getattr(self, 'params', {})
trading_ticker = params.get('trading_ticker', ticker)
logger.info(f"Closing all orders and positions for {trading_ticker}...")
if self.sl_order_id:
try:
self._call_with_retry(self.client.cancel_order, self.sl_order_id)
self.sl_order_id = None
except: pass
exit_price = 0.0
if self.is_in_position:
try:
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:
# Try to close with precision fix
try:
self._call_with_retry(self.client.place_market_order, trading_ticker, -qty)
except Exception as close_e:
if "precision" in str(close_e).lower():
self._call_with_retry(self.client.place_market_order, trading_ticker, round(-qty, 2))
else:
raise close_e
break
except Exception as e:
logger.error(f"Failed to flatten position: {e}")
self.is_in_position = False
return exit_price